diff --git a/src/aphront/AphrontRequest.php b/src/aphront/AphrontRequest.php
index 2561e397b6..8ce2f3e4af 100644
--- a/src/aphront/AphrontRequest.php
+++ b/src/aphront/AphrontRequest.php
@@ -1,970 +1,972 @@
 <?php
 
 /**
  * @task data     Accessing Request Data
  * @task cookie   Managing Cookies
  * @task cluster  Working With a Phabricator Cluster
  */
 final class AphrontRequest extends Phobject {
 
   // NOTE: These magic request-type parameters are automatically included in
   // certain requests (e.g., by phabricator_form(), JX.Request,
   // JX.Workflow, and ConduitClient) and help us figure out what sort of
   // response the client expects.
 
   const TYPE_AJAX = '__ajax__';
   const TYPE_FORM = '__form__';
   const TYPE_CONDUIT = '__conduit__';
   const TYPE_WORKFLOW = '__wflow__';
   const TYPE_CONTINUE = '__continue__';
   const TYPE_PREVIEW = '__preview__';
   const TYPE_HISEC = '__hisec__';
   const TYPE_QUICKSAND = '__quicksand__';
 
   private $host;
   private $path;
   private $requestData;
   private $user;
   private $applicationConfiguration;
   private $site;
   private $controller;
   private $uriData = array();
   private $cookiePrefix;
   private $submitKey;
 
   public function __construct($host, $path) {
     $this->host = $host;
     $this->path = $path;
   }
 
   public function setURIMap(array $uri_data) {
     $this->uriData = $uri_data;
     return $this;
   }
 
   public function getURIMap() {
     return $this->uriData;
   }
 
   public function getURIData($key, $default = null) {
     return idx($this->uriData, $key, $default);
   }
 
   /**
    * Read line range parameter data from the request.
    *
    * Applications like Paste, Diffusion, and Harbormaster use "$12-14" in the
    * URI to allow users to link to particular lines.
    *
-   * @param string URI data key to pull line range information from.
-   * @param int|null Maximum length of the range.
+   * @param string $key URI data key to pull line range information from.
+   * @param int|null $limit Maximum length of the range.
    * @return null|pair<int, int> Null, or beginning and end of the range.
    */
   public function getURILineRange($key, $limit) {
     $range = $this->getURIData($key);
     return self::parseURILineRange($range, $limit);
   }
 
   public static function parseURILineRange($range, $limit) {
     if (!phutil_nonempty_string($range)) {
       return null;
     }
 
     $range = explode('-', $range, 2);
 
     foreach ($range as $key => $value) {
       $value = (int)$value;
       if (!$value) {
         // If either value is "0", discard the range.
         return null;
       }
       $range[$key] = $value;
     }
 
     // If the range is like "$10", treat it like "$10-10".
     if (count($range) == 1) {
       $range[] = head($range);
     }
 
     // If the range is "$7-5", treat it like "$5-7".
     if ($range[1] < $range[0]) {
       $range = array_reverse($range);
     }
 
     // If the user specified something like "$1-999999999" and we have a limit,
     // clamp it to a more reasonable range.
     if ($limit !== null) {
       if ($range[1] - $range[0] > $limit) {
         $range[1] = $range[0] + $limit;
       }
     }
 
     return $range;
   }
 
   public function setApplicationConfiguration(
     $application_configuration) {
     $this->applicationConfiguration = $application_configuration;
     return $this;
   }
 
   public function getApplicationConfiguration() {
     return $this->applicationConfiguration;
   }
 
   public function setPath($path) {
     $this->path = $path;
     return $this;
   }
 
   public function getPath() {
     return $this->path;
   }
 
   public function getHost() {
     // The "Host" header may include a port number, or may be a malicious
     // header in the form "realdomain.com:ignored@evil.com". Invoke the full
     // parser to extract the real domain correctly. See here for coverage of
     // a similar issue in Django:
     //
     //  https://www.djangoproject.com/weblog/2012/oct/17/security/
     $uri = new PhutilURI('http://'.$this->host);
     return $uri->getDomain();
   }
 
   public function setSite(AphrontSite $site) {
     $this->site = $site;
     return $this;
   }
 
   public function getSite() {
     return $this->site;
   }
 
   public function setController(AphrontController $controller) {
     $this->controller = $controller;
     return $this;
   }
 
   public function getController() {
     return $this->controller;
   }
 
 
 /* -(  Accessing Request Data  )--------------------------------------------- */
 
 
   /**
    * @task data
    */
   public function setRequestData(array $request_data) {
     $this->requestData = $request_data;
     return $this;
   }
 
 
   /**
    * @task data
    */
   public function getRequestData() {
     return $this->requestData;
   }
 
 
   /**
    * @task data
    */
   public function getInt($name, $default = null) {
     if (isset($this->requestData[$name])) {
       // Converting from array to int is "undefined". Don't rely on whatever
       // PHP decides to do.
       if (is_array($this->requestData[$name])) {
         return $default;
       }
       return (int)$this->requestData[$name];
     } else {
       return $default;
     }
   }
 
 
   /**
    * @task data
    */
   public function getBool($name, $default = null) {
     if (isset($this->requestData[$name])) {
       if ($this->requestData[$name] === 'true') {
         return true;
       } else if ($this->requestData[$name] === 'false') {
         return false;
       } else {
         return (bool)$this->requestData[$name];
       }
     } else {
       return $default;
     }
   }
 
 
   /**
    * @task data
    */
   public function getStr($name, $default = null) {
     if (isset($this->requestData[$name])) {
       $str = (string)$this->requestData[$name];
       // Normalize newline craziness.
       $str = str_replace(
         array("\r\n", "\r"),
         array("\n", "\n"),
         $str);
       return $str;
     } else {
       return $default;
     }
   }
 
 
   /**
    * @task data
    */
   public function getJSONMap($name, $default = array()) {
     if (!isset($this->requestData[$name])) {
       return $default;
     }
 
     $raw_data = phutil_string_cast($this->requestData[$name]);
     $raw_data = trim($raw_data);
     if (!phutil_nonempty_string($raw_data)) {
       return $default;
     }
 
     if ($raw_data[0] !== '{') {
       throw new Exception(
         pht(
           'Request parameter "%s" is not formatted properly. Expected a '.
           'JSON object, but value does not start with "{".',
           $name));
     }
 
     try {
       $json_object = phutil_json_decode($raw_data);
     } catch (PhutilJSONParserException $ex) {
       throw new Exception(
         pht(
           'Request parameter "%s" is not formatted properly. Expected a '.
           'JSON object, but encountered a syntax error: %s.',
           $name,
           $ex->getMessage()));
     }
 
     return $json_object;
   }
 
 
   /**
    * @task data
    */
   public function getArr($name, $default = array()) {
     if (isset($this->requestData[$name]) &&
         is_array($this->requestData[$name])) {
       return $this->requestData[$name];
     } else {
       return $default;
     }
   }
 
 
   /**
    * @task data
    */
   public function getStrList($name, $default = array()) {
     if (!isset($this->requestData[$name])) {
       return $default;
     }
     $list = $this->getStr($name);
     $list = preg_split('/[\s,]+/', $list, $limit = -1, PREG_SPLIT_NO_EMPTY);
     return $list;
   }
 
 
   /**
    * @task data
    */
   public function getExists($name) {
     return array_key_exists($name, $this->requestData);
   }
 
   public function getFileExists($name) {
     return isset($_FILES[$name]) &&
            (idx($_FILES[$name], 'error') !== UPLOAD_ERR_NO_FILE);
   }
 
   public function isHTTPGet() {
     return ($_SERVER['REQUEST_METHOD'] == 'GET');
   }
 
   public function isHTTPPost() {
     return ($_SERVER['REQUEST_METHOD'] == 'POST');
   }
 
   public function isAjax() {
     return $this->getExists(self::TYPE_AJAX) && !$this->isQuicksand();
   }
 
   public function isWorkflow() {
     return $this->getExists(self::TYPE_WORKFLOW) && !$this->isQuicksand();
   }
 
   public function isQuicksand() {
     return $this->getExists(self::TYPE_QUICKSAND);
   }
 
   public function isConduit() {
     return $this->getExists(self::TYPE_CONDUIT);
   }
 
   public static function getCSRFTokenName() {
     return '__csrf__';
   }
 
   public static function getCSRFHeaderName() {
     return 'X-Phabricator-Csrf';
   }
 
   public static function getViaHeaderName() {
     return 'X-Phabricator-Via';
   }
 
   public function validateCSRF() {
     $token_name = self::getCSRFTokenName();
     $token = $this->getStr($token_name);
 
     // No token in the request, check the HTTP header which is added for Ajax
     // requests.
     if (empty($token)) {
       $token = self::getHTTPHeader(self::getCSRFHeaderName());
     }
 
     $valid = $this->getUser()->validateCSRFToken($token);
     if (!$valid) {
 
       // Add some diagnostic details so we can figure out if some CSRF issues
       // are JS problems or people accessing Ajax URIs directly with their
       // browsers.
       $info = array();
 
       $info[] = pht(
         'You are trying to save some data to permanent storage, but the '.
         'request your browser made included an incorrect token. Reload the '.
         'page and try again. You may need to clear your cookies.');
 
       if ($this->isAjax()) {
         $info[] = pht('This was an Ajax request.');
       } else {
         $info[] = pht('This was a Web request.');
       }
 
       if ($token) {
         $info[] = pht('This request had an invalid CSRF token.');
       } else {
         $info[] = pht('This request had no CSRF token.');
       }
 
       // Give a more detailed explanation of how to avoid the exception
       // in developer mode.
       if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) {
         // TODO: Clean this up, see T1921.
         $info[] = pht(
           "To avoid this error, use %s to construct forms. If you are already ".
           "using %s, make sure the form 'action' uses a relative URI (i.e., ".
           "begins with a '%s'). Forms using absolute URIs do not include CSRF ".
           "tokens, to prevent leaking tokens to external sites.\n\n".
           "If this page performs writes which do not require CSRF protection ".
           "(usually, filling caches or logging), you can use %s to ".
           "temporarily bypass CSRF protection while writing. You should use ".
           "this only for writes which can not be protected with normal CSRF ".
           "mechanisms.\n\n".
           "Some UI elements (like %s) also have methods which will allow you ".
           "to render links as forms (like %s).",
           'phabricator_form()',
           'phabricator_form()',
           '/',
           'AphrontWriteGuard::beginScopedUnguardedWrites()',
           'PhabricatorActionListView',
           'setRenderAsForm(true)');
       }
 
       $message = implode("\n", $info);
 
       // This should only be able to happen if you load a form, pull your
       // internet for 6 hours, and then reconnect and immediately submit,
       // but give the user some indication of what happened since the workflow
       // is incredibly confusing otherwise.
       throw new AphrontMalformedRequestException(
         pht('Invalid Request (CSRF)'),
         $message,
         true);
     }
 
     return true;
   }
 
   public function isFormPost() {
     $post = $this->getExists(self::TYPE_FORM) &&
             !$this->getExists(self::TYPE_HISEC) &&
             $this->isHTTPPost();
 
     if (!$post) {
       return false;
     }
 
     return $this->validateCSRF();
   }
 
   public function hasCSRF() {
     try {
       $this->validateCSRF();
       return true;
     } catch (AphrontMalformedRequestException $ex) {
       return false;
     }
   }
 
   public function isFormOrHisecPost() {
     $post = $this->getExists(self::TYPE_FORM) &&
             $this->isHTTPPost();
 
     if (!$post) {
       return false;
     }
 
     return $this->validateCSRF();
   }
 
 
   public function setCookiePrefix($prefix) {
     $this->cookiePrefix = $prefix;
     return $this;
   }
 
   private function getPrefixedCookieName($name) {
     if (phutil_nonempty_string($this->cookiePrefix)) {
       return $this->cookiePrefix.'_'.$name;
     } else {
       return $name;
     }
   }
 
   public function getCookie($name, $default = null) {
     $name = $this->getPrefixedCookieName($name);
     $value = idx($_COOKIE, $name, $default);
 
     // Internally, PHP deletes cookies by setting them to the value 'deleted'
     // with an expiration date in the past.
 
     // At least in Safari, the browser may send this cookie anyway in some
     // circumstances. After logging out, the 302'd GET to /login/ consistently
     // includes deleted cookies on my local install. If a cookie value is
     // literally 'deleted', pretend it does not exist.
 
     if ($value === 'deleted') {
       return null;
     }
 
     return $value;
   }
 
   public function clearCookie($name) {
     $this->setCookieWithExpiration($name, '', time() - (60 * 60 * 24 * 30));
     unset($_COOKIE[$name]);
   }
 
   /**
    * Get the domain which cookies should be set on for this request, or null
    * if the request does not correspond to a valid cookie domain.
    *
    * @return PhutilURI|null   Domain URI, or null if no valid domain exists.
    *
    * @task cookie
    */
   private function getCookieDomainURI() {
     if (PhabricatorEnv::getEnvConfig('security.require-https') &&
         !$this->isHTTPS()) {
       return null;
     }
 
     $host = $this->getHost();
 
     // If there's no base domain configured, just use whatever the request
     // domain is. This makes setup easier, and we'll tell administrators to
     // configure a base domain during the setup process.
     $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
     if (!phutil_nonempty_string($base_uri)) {
       return new PhutilURI('http://'.$host.'/');
     }
 
     $alternates = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris');
     $allowed_uris = array_merge(
       array($base_uri),
       $alternates);
 
     foreach ($allowed_uris as $allowed_uri) {
       $uri = new PhutilURI($allowed_uri);
       if ($uri->getDomain() == $host) {
         return $uri;
       }
     }
 
     return null;
   }
 
   /**
    * Determine if security policy rules will allow cookies to be set when
    * responding to the request.
    *
    * @return bool True if setCookie() will succeed. If this method returns
    *              false, setCookie() will throw.
    *
    * @task cookie
    */
   public function canSetCookies() {
     return (bool)$this->getCookieDomainURI();
   }
 
 
   /**
    * Set a cookie which does not expire for a long time.
    *
    * To set a temporary cookie, see @{method:setTemporaryCookie}.
    *
-   * @param string  Cookie name.
-   * @param string  Cookie value.
+   * @param string $name Cookie name.
+   * @param string $value Cookie value.
    * @return this
    * @task cookie
    */
   public function setCookie($name, $value) {
     $far_future = time() + (60 * 60 * 24 * 365 * 5);
     return $this->setCookieWithExpiration($name, $value, $far_future);
   }
 
 
   /**
    * Set a cookie which expires soon.
    *
    * To set a durable cookie, see @{method:setCookie}.
    *
-   * @param string  Cookie name.
-   * @param string  Cookie value.
+   * @param string $name Cookie name.
+   * @param string $value Cookie value.
    * @return this
    * @task cookie
    */
   public function setTemporaryCookie($name, $value) {
     return $this->setCookieWithExpiration($name, $value, 0);
   }
 
 
   /**
    * Set a cookie with a given expiration policy.
    *
-   * @param string  Cookie name.
-   * @param string  Cookie value.
-   * @param int     Epoch timestamp for cookie expiration.
+   * @param string $name Cookie name.
+   * @param string $value Cookie value.
+   * @param int    $expire Epoch timestamp for cookie expiration.
    * @return this
    * @task cookie
    */
   private function setCookieWithExpiration(
     $name,
     $value,
     $expire) {
 
     $is_secure = false;
 
     $base_domain_uri = $this->getCookieDomainURI();
     if (!$base_domain_uri) {
       $configured_as = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
       $accessed_as = $this->getHost();
 
       throw new AphrontMalformedRequestException(
         pht('Bad Host Header'),
         pht(
           'This server is configured as "%s", but you are using the domain '.
           'name "%s" to access a page which is trying to set a cookie. '.
           'Access this service on the configured primary domain or a '.
           'configured alternate domain. Cookies will not be set on other '.
           'domains for security reasons.',
           $configured_as,
           $accessed_as),
         true);
     }
 
     $base_domain = $base_domain_uri->getDomain();
     $is_secure = ($base_domain_uri->getProtocol() == 'https');
 
     $name = $this->getPrefixedCookieName($name);
 
     if (php_sapi_name() == 'cli') {
       // Do nothing, to avoid triggering "Cannot modify header information"
       // warnings.
 
       // TODO: This is effectively a test for whether we're running in a unit
       // test or not. Move this actual call to HTTPSink?
     } else {
       setcookie(
         $name,
         $value,
         $expire,
         $path = '/',
         $base_domain,
         $is_secure,
         $http_only = true);
     }
 
     $_COOKIE[$name] = $value;
 
     return $this;
   }
 
   public function setUser($user) {
     $this->user = $user;
     return $this;
   }
 
   public function getUser() {
     return $this->user;
   }
 
   public function getViewer() {
     return $this->user;
   }
 
   public function getRequestURI() {
     $uri_path = phutil_escape_uri($this->getPath());
     $uri_query = idx($_SERVER, 'QUERY_STRING', '');
 
     return id(new PhutilURI($uri_path.'?'.$uri_query))
       ->removeQueryParam('__path__');
   }
 
   public function getAbsoluteRequestURI() {
     $uri = $this->getRequestURI();
     $uri->setDomain($this->getHost());
 
     if ($this->isHTTPS()) {
       $protocol = 'https';
     } else {
       $protocol = 'http';
     }
 
     $uri->setProtocol($protocol);
 
     // If the request used a nonstandard port, preserve it while building the
     // absolute URI.
 
     // First, get the default port for the request protocol.
     $default_port = id(new PhutilURI($protocol.'://example.com/'))
       ->getPortWithProtocolDefault();
 
     // NOTE: See note in getHost() about malicious "Host" headers. This
     // construction defuses some obscure potential attacks.
     $port = id(new PhutilURI($protocol.'://'.$this->host))
       ->getPort();
 
     if (($port !== null) && ($port !== $default_port)) {
       $uri->setPort($port);
     }
 
     return $uri;
   }
 
   public function isDialogFormPost() {
     return $this->isFormPost() && $this->getStr('__dialog__');
   }
 
   public function getRemoteAddress() {
     $address = PhabricatorEnv::getRemoteAddress();
 
     if (!$address) {
       return null;
     }
 
     return $address->getAddress();
   }
 
   public function isHTTPS() {
     if (empty($_SERVER['HTTPS'])) {
       return false;
     }
     if (!strcasecmp($_SERVER['HTTPS'], 'off')) {
       return false;
     }
     return true;
   }
 
   public function isContinueRequest() {
     return $this->isFormOrHisecPost() && $this->getStr('__continue__');
   }
 
   public function isPreviewRequest() {
     return $this->isFormPost() && $this->getStr('__preview__');
   }
 
   /**
    * Get application request parameters in a flattened form suitable for
    * inclusion in an HTTP request, excluding parameters with special meanings.
    * This is primarily useful if you want to ask the user for more input and
    * then resubmit their request.
    *
    * @return  dict<string, string>  Original request parameters.
    */
   public function getPassthroughRequestParameters($include_quicksand = false) {
     return self::flattenData(
       $this->getPassthroughRequestData($include_quicksand));
   }
 
   /**
    * Get request data other than "magic" parameters.
    *
    * @return dict<string, wild> Request data, with magic filtered out.
    */
   public function getPassthroughRequestData($include_quicksand = false) {
     $data = $this->getRequestData();
 
     // Remove magic parameters like __dialog__ and __ajax__.
     foreach ($data as $key => $value) {
       if ($include_quicksand && $key == self::TYPE_QUICKSAND) {
         continue;
       }
       if (!strncmp($key, '__', 2)) {
         unset($data[$key]);
       }
     }
 
     return $data;
   }
 
 
   /**
    * Flatten an array of key-value pairs (possibly including arrays as values)
    * into a list of key-value pairs suitable for submitting via HTTP request
    * (with arrays flattened).
    *
-   * @param   dict<string, wild>    Data to flatten.
+   * @param   dict<string, wild>    $data Data to flatten.
    * @return  dict<string, string>  Flat data suitable for inclusion in an HTTP
    *                                request.
    */
   public static function flattenData(array $data) {
     $result = array();
     foreach ($data as $key => $value) {
       if (is_array($value)) {
         foreach (self::flattenData($value) as $fkey => $fvalue) {
           $fkey = '['.preg_replace('/(?=\[)|$/', ']', $fkey, $limit = 1);
           $result[$key.$fkey] = $fvalue;
         }
       } else {
         $result[$key] = (string)$value;
       }
     }
 
     ksort($result);
 
     return $result;
   }
 
 
   /**
    * Read the value of an HTTP header from `$_SERVER`, or a similar datasource.
    *
    * This function accepts a canonical header name, like `"Accept-Encoding"`,
    * and looks up the appropriate value in `$_SERVER` (in this case,
    * `"HTTP_ACCEPT_ENCODING"`).
    *
-   * @param   string        Canonical header name, like `"Accept-Encoding"`.
-   * @param   wild          Default value to return if header is not present.
-   * @param   array?        Read this instead of `$_SERVER`.
+   * @param   string        $name Canonical header name, like
+        `"Accept-Encoding"`.
+   * @param   wild?         $default Default value to return if header is not
+        present.
+   * @param   array?        $data Read this instead of `$_SERVER`.
    * @return  string|wild   Header value if present, or `$default` if not.
    */
   public static function getHTTPHeader($name, $default = null, $data = null) {
     // PHP mangles HTTP headers by uppercasing them and replacing hyphens with
     // underscores, then prepending 'HTTP_'.
     $php_index = strtoupper($name);
     $php_index = str_replace('-', '_', $php_index);
 
     $try_names = array();
 
     $try_names[] = 'HTTP_'.$php_index;
     if ($php_index == 'CONTENT_TYPE' || $php_index == 'CONTENT_LENGTH') {
       // These headers may be available under alternate names. See
       // http://www.php.net/manual/en/reserved.variables.server.php#110763
       $try_names[] = $php_index;
     }
 
     if ($data === null) {
       $data = $_SERVER;
     }
 
     foreach ($try_names as $try_name) {
       if (array_key_exists($try_name, $data)) {
         return $data[$try_name];
       }
     }
 
     return $default;
   }
 
 
 /* -(  Working With a Phabricator Cluster  )--------------------------------- */
 
 
   /**
    * Is this a proxied request originating from within the Phabricator cluster?
    *
    * IMPORTANT: This means the request is dangerous!
    *
    * These requests are **more dangerous** than normal requests (they can not
    * be safely proxied, because proxying them may cause a loop). Cluster
    * requests are not guaranteed to come from a trusted source, and should
    * never be treated as safer than normal requests. They are strictly less
    * safe.
    */
   public function isProxiedClusterRequest() {
     return (bool)self::getHTTPHeader('X-Phabricator-Cluster');
   }
 
 
   /**
    * Build a new @{class:HTTPSFuture} which proxies this request to another
    * node in the cluster.
    *
    * IMPORTANT: This is very dangerous!
    *
    * The future forwards authentication information present in the request.
    * Proxied requests must only be sent to trusted hosts. (We attempt to
    * enforce this.)
    *
    * This is not a general-purpose proxying method; it is a specialized
    * method with niche applications and severe security implications.
    *
-   * @param string URI identifying the host we are proxying the request to.
+   * @param string URI $uri identifying the host we are proxying the request to.
    * @return HTTPSFuture New proxy future.
    *
    * @phutil-external-symbol class PhabricatorStartup
    */
   public function newClusterProxyFuture($uri) {
     $uri = new PhutilURI($uri);
 
     $domain = $uri->getDomain();
     $ip = gethostbyname($domain);
     if (!$ip) {
       throw new Exception(
         pht(
           'Unable to resolve domain "%s"!',
           $domain));
     }
 
     if (!PhabricatorEnv::isClusterAddress($ip)) {
       throw new Exception(
         pht(
           'Refusing to proxy a request to IP address ("%s") which is not '.
           'in the cluster address block (this address was derived by '.
           'resolving the domain "%s").',
           $ip,
           $domain));
     }
 
     $uri->setPath($this->getPath());
     $uri->removeAllQueryParams();
     foreach (self::flattenData($_GET) as $query_key => $query_value) {
       $uri->appendQueryParam($query_key, $query_value);
     }
 
     $input = PhabricatorStartup::getRawInput();
 
     $future = id(new HTTPSFuture($uri))
       ->addHeader('Host', self::getHost())
       ->addHeader('X-Phabricator-Cluster', true)
       ->setMethod($_SERVER['REQUEST_METHOD'])
       ->write($input);
 
     if (isset($_SERVER['PHP_AUTH_USER'])) {
       $future->setHTTPBasicAuthCredentials(
         $_SERVER['PHP_AUTH_USER'],
         new PhutilOpaqueEnvelope(idx($_SERVER, 'PHP_AUTH_PW', '')));
     }
 
     $headers = array();
     $seen = array();
 
     // NOTE: apache_request_headers() might provide a nicer way to do this,
     // but isn't available under FCGI until PHP 5.4.0.
     foreach ($_SERVER as $key => $value) {
       if (!preg_match('/^HTTP_/', $key)) {
         continue;
       }
 
       // Unmangle the header as best we can.
       $key = substr($key, strlen('HTTP_'));
       $key = str_replace('_', ' ', $key);
       $key = strtolower($key);
       $key = ucwords($key);
       $key = str_replace(' ', '-', $key);
 
       // By default, do not forward headers.
       $should_forward = false;
 
       // Forward "X-Hgarg-..." headers.
       if (preg_match('/^X-Hgarg-/', $key)) {
         $should_forward = true;
       }
 
       if ($should_forward) {
         $headers[] = array($key, $value);
         $seen[$key] = true;
       }
     }
 
     // In some situations, this may not be mapped into the HTTP_X constants.
     // CONTENT_LENGTH is similarly affected, but we trust cURL to take care
     // of that if it matters, since we're handing off a request body.
     if (empty($seen['Content-Type'])) {
       if (isset($_SERVER['CONTENT_TYPE'])) {
         $headers[] = array('Content-Type', $_SERVER['CONTENT_TYPE']);
       }
     }
 
     foreach ($headers as $header) {
       list($key, $value) = $header;
       switch ($key) {
         case 'Host':
         case 'Authorization':
           // Don't forward these headers, we've already handled them elsewhere.
           unset($headers[$key]);
           break;
         default:
           break;
       }
     }
 
     foreach ($headers as $header) {
       list($key, $value) = $header;
       $future->addHeader($key, $value);
     }
 
     return $future;
   }
 
   public function updateEphemeralCookies() {
     $submit_cookie = PhabricatorCookies::COOKIE_SUBMIT;
 
     $submit_key = $this->getCookie($submit_cookie);
     if (phutil_nonempty_string($submit_key)) {
       $this->clearCookie($submit_cookie);
       $this->submitKey = $submit_key;
     }
 
   }
 
   public function getSubmitKey() {
     return $this->submitKey;
   }
 
 }
diff --git a/src/aphront/configuration/AphrontApplicationConfiguration.php b/src/aphront/configuration/AphrontApplicationConfiguration.php
index 3198bb9fd4..af05993e2b 100644
--- a/src/aphront/configuration/AphrontApplicationConfiguration.php
+++ b/src/aphront/configuration/AphrontApplicationConfiguration.php
@@ -1,880 +1,881 @@
 <?php
 
 /**
  * @task routing URI Routing
  * @task response Response Handling
  * @task exception Exception Handling
  */
 final class AphrontApplicationConfiguration
   extends Phobject {
 
   private $request;
   private $host;
   private $path;
   private $console;
 
   public function buildRequest() {
     $parser = new PhutilQueryStringParser();
 
     $data = array();
     $data += $_POST;
     $data += $parser->parseQueryString(idx($_SERVER, 'QUERY_STRING', ''));
 
     $cookie_prefix = PhabricatorEnv::getEnvConfig('phabricator.cookie-prefix');
 
     $request = new AphrontRequest($this->getHost(), $this->getPath());
     $request->setRequestData($data);
     $request->setApplicationConfiguration($this);
     $request->setCookiePrefix($cookie_prefix);
 
     $request->updateEphemeralCookies();
 
     return $request;
   }
 
   public function buildRedirectController($uri, $external) {
     return array(
       new PhabricatorRedirectController(),
       array(
         'uri' => $uri,
         'external' => $external,
       ),
     );
   }
 
   public function setRequest(AphrontRequest $request) {
     $this->request = $request;
     return $this;
   }
 
   public function getRequest() {
     return $this->request;
   }
 
   public function getConsole() {
     return $this->console;
   }
 
   public function setConsole($console) {
     $this->console = $console;
     return $this;
   }
 
   public function setHost($host) {
     $this->host = $host;
     return $this;
   }
 
   public function getHost() {
     return $this->host;
   }
 
   public function setPath($path) {
     $this->path = $path;
     return $this;
   }
 
   public function getPath() {
     return $this->path;
   }
 
 
   /**
    * @phutil-external-symbol class PhabricatorStartup
    */
   public static function runHTTPRequest(AphrontHTTPSink $sink) {
     if (isset($_SERVER['HTTP_X_SETUP_SELFCHECK'])) {
       $response = self::newSelfCheckResponse();
       return self::writeResponse($sink, $response);
     }
 
     PhabricatorStartup::beginStartupPhase('multimeter');
     $multimeter = MultimeterControl::newInstance();
     $multimeter->setEventContext('<http-init>');
     $multimeter->setEventViewer('<none>');
 
     // Build a no-op write guard for the setup phase. We'll replace this with a
     // real write guard later on, but we need to survive setup and build a
     // request object first.
     $write_guard = new AphrontWriteGuard('id');
 
     PhabricatorStartup::beginStartupPhase('preflight');
 
     $response = PhabricatorSetupCheck::willPreflightRequest();
     if ($response) {
       return self::writeResponse($sink, $response);
     }
 
     PhabricatorStartup::beginStartupPhase('env.init');
 
     self::readHTTPPOSTData();
 
     try {
       PhabricatorEnv::initializeWebEnvironment();
       $database_exception = null;
     } catch (PhabricatorClusterStrandedException $ex) {
       $database_exception = $ex;
     }
 
     // If we're in developer mode, set a flag so that top-level exception
     // handlers can add more information.
     if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) {
       $sink->setShowStackTraces(true);
     }
 
     if ($database_exception) {
       $issue = PhabricatorSetupIssue::newDatabaseConnectionIssue(
         $database_exception,
         true);
       $response = PhabricatorSetupCheck::newIssueResponse($issue);
       return self::writeResponse($sink, $response);
     }
 
     $multimeter->setSampleRate(
       PhabricatorEnv::getEnvConfig('debug.sample-rate'));
 
     $debug_time_limit = PhabricatorEnv::getEnvConfig('debug.time-limit');
     if ($debug_time_limit) {
       PhabricatorStartup::setDebugTimeLimit($debug_time_limit);
     }
 
     // This is the earliest we can get away with this, we need env config first.
     PhabricatorStartup::beginStartupPhase('log.access');
     PhabricatorAccessLog::init();
     $access_log = PhabricatorAccessLog::getLog();
     PhabricatorStartup::setAccessLog($access_log);
 
     $address = PhabricatorEnv::getRemoteAddress();
     if ($address) {
       $address_string = $address->getAddress();
     } else {
       $address_string = '-';
     }
 
     $access_log->setData(
       array(
         'R' => AphrontRequest::getHTTPHeader('Referer', '-'),
         'r' => $address_string,
         'M' => idx($_SERVER, 'REQUEST_METHOD', '-'),
       ));
 
     DarkConsoleXHProfPluginAPI::hookProfiler();
 
     // We just activated the profiler, so we don't need to keep track of
     // startup phases anymore: it can take over from here.
     PhabricatorStartup::beginStartupPhase('startup.done');
 
     DarkConsoleErrorLogPluginAPI::registerErrorHandler();
 
     $response = PhabricatorSetupCheck::willProcessRequest();
     if ($response) {
       return self::writeResponse($sink, $response);
     }
 
     $host = AphrontRequest::getHTTPHeader('Host');
     $path = PhabricatorStartup::getRequestPath();
 
     $application = new self();
 
     $application->setHost($host);
     $application->setPath($path);
     $request = $application->buildRequest();
 
     // Now that we have a request, convert the write guard into one which
     // actually checks CSRF tokens.
     $write_guard->dispose();
     $write_guard = new AphrontWriteGuard(array($request, 'validateCSRF'));
 
     // Build the server URI implied by the request headers. If an administrator
     // has not configured "phabricator.base-uri" yet, we'll use this to generate
     // links.
 
     $request_protocol = ($request->isHTTPS() ? 'https' : 'http');
     $request_base_uri = "{$request_protocol}://{$host}/";
     PhabricatorEnv::setRequestBaseURI($request_base_uri);
 
     $access_log->setData(
       array(
         'U' => (string)$request->getRequestURI()->getPath(),
       ));
 
     $processing_exception = null;
     try {
       $response = $application->processRequest(
         $request,
         $access_log,
         $sink,
         $multimeter);
       $response_code = $response->getHTTPResponseCode();
     } catch (Exception $ex) {
       $processing_exception = $ex;
       $response_code = 500;
     }
 
     $write_guard->dispose();
 
     $access_log->setData(
       array(
         'c' => $response_code,
         'T' => PhabricatorStartup::getMicrosecondsSinceStart(),
       ));
 
     $multimeter->newEvent(
       MultimeterEvent::TYPE_REQUEST_TIME,
       $multimeter->getEventContext(),
       PhabricatorStartup::getMicrosecondsSinceStart());
 
     $access_log->write();
 
     $multimeter->saveEvents();
 
     DarkConsoleXHProfPluginAPI::saveProfilerSample($access_log);
 
     PhabricatorStartup::disconnectRateLimits(
       array(
         'viewer' => $request->getUser(),
       ));
 
     if ($processing_exception) {
       throw $processing_exception;
     }
   }
 
 
   public function processRequest(
     AphrontRequest $request,
     PhutilDeferredLog $access_log,
     AphrontHTTPSink $sink,
     MultimeterControl $multimeter) {
 
     $this->setRequest($request);
 
     list($controller, $uri_data) = $this->buildController();
 
     $controller_class = get_class($controller);
     $access_log->setData(
       array(
         'C' => $controller_class,
       ));
     $multimeter->setEventContext('web.'.$controller_class);
 
     $request->setController($controller);
     $request->setURIMap($uri_data);
 
     $controller->setRequest($request);
 
     // If execution throws an exception and then trying to render that
     // exception throws another exception, we want to show the original
     // exception, as it is likely the root cause of the rendering exception.
     $original_exception = null;
     try {
       $response = $controller->willBeginExecution();
 
       if ($request->getUser() && $request->getUser()->getPHID()) {
         $access_log->setData(
           array(
             'u' => $request->getUser()->getUserName(),
             'P' => $request->getUser()->getPHID(),
           ));
         $multimeter->setEventViewer('user.'.$request->getUser()->getPHID());
       }
 
       if (!$response) {
         $controller->willProcessRequest($uri_data);
         $response = $controller->handleRequest($request);
         $this->validateControllerResponse($controller, $response);
       }
     } catch (Exception $ex) {
       $original_exception = $ex;
     } catch (Throwable $ex) {
       $original_exception = $ex;
     }
 
     $response_exception = null;
     try {
       if ($original_exception) {
         $response = $this->handleThrowable($original_exception);
       }
 
       $response = $this->produceResponse($request, $response);
       $response = $controller->willSendResponse($response);
       $response->setRequest($request);
 
       self::writeResponse($sink, $response);
     } catch (Exception $ex) {
       $response_exception = $ex;
     } catch (Throwable $ex) {
       $response_exception = $ex;
     }
 
     if ($response_exception) {
       // If we encountered an exception while building a normal response, then
       // encountered another exception while building a response for the first
       // exception, throw an aggregate exception that will be unpacked by the
       // higher-level handler. This is above our pay grade.
       if ($original_exception) {
         throw new PhutilAggregateException(
           pht(
             'Encountered a processing exception, then another exception when '.
             'trying to build a response for the first exception.'),
           array(
             $response_exception,
             $original_exception,
           ));
       }
 
       // If we built a response successfully and then ran into an exception
       // trying to render it, try to handle and present that exception to the
       // user using the standard handler.
 
       // The problem here might be in rendering (more common) or in the actual
       // response mechanism (less common). If it's in rendering, we can likely
       // still render a nice exception page: the majority of rendering issues
       // are in main page content, not content shared with the exception page.
 
       $handling_exception = null;
       try {
         $response = $this->handleThrowable($response_exception);
 
         $response = $this->produceResponse($request, $response);
         $response = $controller->willSendResponse($response);
         $response->setRequest($request);
 
         self::writeResponse($sink, $response);
       } catch (Exception $ex) {
         $handling_exception = $ex;
       } catch (Throwable $ex) {
         $handling_exception = $ex;
       }
 
       // If we didn't have any luck with that, raise the original response
       // exception. As above, this is the root cause exception and more likely
       // to be useful. This will go to the fallback error handler at top
       // level.
 
       if ($handling_exception) {
         throw $response_exception;
       }
     }
 
     return $response;
   }
 
   private static function writeResponse(
     AphrontHTTPSink $sink,
     AphrontResponse $response) {
 
     $unexpected_output = PhabricatorStartup::endOutputCapture();
     if ($unexpected_output) {
       $unexpected_output = pht(
         "Unexpected output:\n\n%s",
         $unexpected_output);
 
       phlog($unexpected_output);
 
       if ($response instanceof AphrontWebpageResponse) {
         $response->setUnexpectedOutput($unexpected_output);
       }
     }
 
     $sink->writeResponse($response);
   }
 
 
 /* -(  URI Routing  )-------------------------------------------------------- */
 
 
   /**
    * Build a controller to respond to the request.
    *
    * @return pair<AphrontController,dict> Controller and dictionary of request
    *                                      parameters.
    * @task routing
    */
   private function buildController() {
     $request = $this->getRequest();
 
     // If we're configured to operate in cluster mode, reject requests which
     // were not received on a cluster interface.
     //
     // For example, a host may have an internal address like "170.0.0.1", and
     // also have a public address like "51.23.95.16". Assuming the cluster
     // is configured on a range like "170.0.0.0/16", we want to reject the
     // requests received on the public interface.
     //
     // Ideally, nodes in a cluster should only be listening on internal
     // interfaces, but they may be configured in such a way that they also
     // listen on external interfaces, since this is easy to forget about or
     // get wrong. As a broad security measure, reject requests received on any
     // interfaces which aren't on the whitelist.
 
     $cluster_addresses = PhabricatorEnv::getEnvConfig('cluster.addresses');
     if ($cluster_addresses) {
       $server_addr = idx($_SERVER, 'SERVER_ADDR');
       if (!$server_addr) {
         if (php_sapi_name() == 'cli') {
           // This is a command line script (probably something like a unit
           // test) so it's fine that we don't have SERVER_ADDR defined.
         } else {
           throw new AphrontMalformedRequestException(
             pht('No %s', 'SERVER_ADDR'),
             pht(
               'This service is configured to operate in cluster mode, but '.
               '%s is not defined in the request context. Your webserver '.
               'configuration needs to forward %s to PHP so the software can '.
               'reject requests received on external interfaces.',
               'SERVER_ADDR',
               'SERVER_ADDR'));
         }
       } else {
         if (!PhabricatorEnv::isClusterAddress($server_addr)) {
           throw new AphrontMalformedRequestException(
             pht('External Interface'),
             pht(
               'This service is configured in cluster mode and the address '.
               'this request was received on ("%s") is not whitelisted as '.
               'a cluster address.',
               $server_addr));
         }
       }
     }
 
     $site = $this->buildSiteForRequest($request);
 
     if ($site->shouldRequireHTTPS()) {
       if (!$request->isHTTPS()) {
 
         // Don't redirect intracluster requests: doing so drops headers and
         // parameters, imposes a performance penalty, and indicates a
         // misconfiguration.
         if ($request->isProxiedClusterRequest()) {
           throw new AphrontMalformedRequestException(
             pht('HTTPS Required'),
             pht(
               'This request reached a site which requires HTTPS, but the '.
               'request is not marked as HTTPS.'));
         }
 
         $https_uri = $request->getRequestURI();
         $https_uri->setDomain($request->getHost());
         $https_uri->setProtocol('https');
 
         // In this scenario, we'll be redirecting to HTTPS using an absolute
         // URI, so we need to permit an external redirect.
         return $this->buildRedirectController($https_uri, true);
       }
     }
 
     $maps = $site->getRoutingMaps();
     $path = $request->getPath();
 
     $result = $this->routePath($maps, $path);
     if ($result) {
       return $result;
     }
 
     // If we failed to match anything but don't have a trailing slash, try
     // to add a trailing slash and issue a redirect if that resolves.
 
     // NOTE: We only do this for GET, since redirects switch to GET and drop
     // data like POST parameters.
     if (!preg_match('@/$@', $path) && $request->isHTTPGet()) {
       $result = $this->routePath($maps, $path.'/');
       if ($result) {
         $target_uri = $request->getAbsoluteRequestURI();
 
         // We need to restore URI encoding because the webserver has
         // interpreted it. For example, this allows us to redirect a path
         // like `/tag/aa%20bb` to `/tag/aa%20bb/`, which may eventually be
         // resolved meaningfully by an application.
         $target_path = phutil_escape_uri($path.'/');
         $target_uri->setPath($target_path);
         $target_uri = (string)$target_uri;
 
         return $this->buildRedirectController($target_uri, true);
       }
     }
 
     $result = $site->new404Controller($request);
     if ($result) {
       return array($result, array());
     }
 
     throw new Exception(
       pht(
         'Aphront site ("%s") failed to build a 404 controller.',
         get_class($site)));
   }
 
   /**
    * Map a specific path to the corresponding controller. For a description
    * of routing, see @{method:buildController}.
    *
-   * @param list<AphrontRoutingMap> List of routing maps.
-   * @param string Path to route.
+   * @param list<AphrontRoutingMap> $maps List of routing maps.
+   * @param string $path Path to route.
    * @return pair<AphrontController,dict> Controller and dictionary of request
    *                                      parameters.
    * @task routing
    */
   private function routePath(array $maps, $path) {
     foreach ($maps as $map) {
       $result = $map->routePath($path);
       if ($result) {
         return array($result->getController(), $result->getURIData());
       }
     }
     return null;
   }
 
   private function buildSiteForRequest(AphrontRequest $request) {
     $sites = PhabricatorSite::getAllSites();
 
     $site = null;
     foreach ($sites as $candidate) {
       $site = $candidate->newSiteForRequest($request);
       if ($site) {
         break;
       }
     }
 
     if (!$site) {
       $path = $request->getPath();
       $host = $request->getHost();
       throw new AphrontMalformedRequestException(
         pht('Site Not Found'),
         pht(
           'This request asked for "%s" on host "%s", but no site is '.
           'configured which can serve this request.',
           $path,
           $host),
         true);
     }
 
     $request->setSite($site);
 
     return $site;
   }
 
 
 /* -(  Response Handling  )-------------------------------------------------- */
 
 
   /**
    * Tests if a response is of a valid type.
    *
-   * @param wild Supposedly valid response.
+   * @param wild $response Supposedly valid response.
    * @return bool True if the object is of a valid type.
    * @task response
    */
   private function isValidResponseObject($response) {
     if ($response instanceof AphrontResponse) {
       return true;
     }
 
     if ($response instanceof AphrontResponseProducerInterface) {
       return true;
     }
 
     return false;
   }
 
 
   /**
    * Verifies that the return value from an @{class:AphrontController} is
    * of an allowed type.
    *
-   * @param AphrontController Controller which returned the response.
-   * @param wild Supposedly valid response.
+   * @param AphrontController $controller Controller which returned the
+   *   response.
+   * @param wild $response Supposedly valid response.
    * @return void
    * @task response
    */
   private function validateControllerResponse(
     AphrontController $controller,
     $response) {
 
     if ($this->isValidResponseObject($response)) {
       return;
     }
 
     throw new Exception(
       pht(
         'Controller "%s" returned an invalid response from call to "%s". '.
         'This method must return an object of class "%s", or an object '.
         'which implements the "%s" interface.',
         get_class($controller),
         'handleRequest()',
         'AphrontResponse',
         'AphrontResponseProducerInterface'));
   }
 
 
   /**
    * Verifies that the return value from an
    * @{class:AphrontResponseProducerInterface} is of an allowed type.
    *
-   * @param AphrontResponseProducerInterface Object which produced
+   * @param AphrontResponseProducerInterface $producer Object which produced
    *   this response.
-   * @param wild Supposedly valid response.
+   * @param wild $response Supposedly valid response.
    * @return void
    * @task response
    */
   private function validateProducerResponse(
     AphrontResponseProducerInterface $producer,
     $response) {
 
     if ($this->isValidResponseObject($response)) {
       return;
     }
 
     throw new Exception(
       pht(
         'Producer "%s" returned an invalid response from call to "%s". '.
         'This method must return an object of class "%s", or an object '.
         'which implements the "%s" interface.',
         get_class($producer),
         'produceAphrontResponse()',
         'AphrontResponse',
         'AphrontResponseProducerInterface'));
   }
 
 
   /**
    * Verifies that the return value from an
    * @{class:AphrontRequestExceptionHandler} is of an allowed type.
    *
-   * @param AphrontRequestExceptionHandler Object which produced this
+   * @param AphrontRequestExceptionHandler $handler Object which produced this
    *  response.
-   * @param wild Supposedly valid response.
+   * @param wild $response Supposedly valid response.
    * @return void
    * @task response
    */
   private function validateErrorHandlerResponse(
     AphrontRequestExceptionHandler $handler,
     $response) {
 
     if ($this->isValidResponseObject($response)) {
       return;
     }
 
     throw new Exception(
       pht(
         'Exception handler "%s" returned an invalid response from call to '.
         '"%s". This method must return an object of class "%s", or an object '.
         'which implements the "%s" interface.',
         get_class($handler),
         'handleRequestException()',
         'AphrontResponse',
         'AphrontResponseProducerInterface'));
   }
 
 
   /**
    * Resolves a response object into an @{class:AphrontResponse}.
    *
    * Controllers are permitted to return actual responses of class
    * @{class:AphrontResponse}, or other objects which implement
    * @{interface:AphrontResponseProducerInterface} and can produce a response.
    *
    * If a controller returns a response producer, invoke it now and produce
    * the real response.
    *
-   * @param AphrontRequest Request being handled.
-   * @param AphrontResponse|AphrontResponseProducerInterface Response, or
-   *   response producer.
+   * @param AphrontRequest $request Request being handled.
+   * @param AphrontResponse|AphrontResponseProducerInterface $response
+   *   Response, or response producer.
    * @return AphrontResponse Response after any required production.
    * @task response
    */
   private function produceResponse(AphrontRequest $request, $response) {
     $original = $response;
 
     // Detect cycles on the exact same objects. It's still possible to produce
     // infinite responses as long as they're all unique, but we can only
     // reasonably detect cycles, not guarantee that response production halts.
 
     $seen = array();
     while (true) {
       // NOTE: It is permissible for an object to be both a response and a
       // response producer. If so, being a producer is "stronger". This is
       // used by AphrontProxyResponse.
 
       // If this response is a valid response, hand over the request first.
       if ($response instanceof AphrontResponse) {
         $response->setRequest($request);
       }
 
       // If this isn't a producer, we're all done.
       if (!($response instanceof AphrontResponseProducerInterface)) {
         break;
       }
 
       $hash = spl_object_hash($response);
       if (isset($seen[$hash])) {
         throw new Exception(
           pht(
             'Failure while producing response for object of class "%s": '.
             'encountered production cycle (identical object, of class "%s", '.
             'was produced twice).',
             get_class($original),
             get_class($response)));
       }
 
       $seen[$hash] = true;
 
       $new_response = $response->produceAphrontResponse();
       $this->validateProducerResponse($response, $new_response);
       $response = $new_response;
     }
 
     return $response;
   }
 
 
 /* -(  Error Handling  )----------------------------------------------------- */
 
 
   /**
    * Convert an exception which has escaped the controller into a response.
    *
    * This method delegates exception handling to available subclasses of
    * @{class:AphrontRequestExceptionHandler}.
    *
-   * @param Throwable Exception which needs to be handled.
+   * @param Throwable $throwable Exception which needs to be handled.
    * @return wild Response or response producer, or null if no available
    *   handler can produce a response.
    * @task exception
    */
   private function handleThrowable($throwable) {
     $handlers = AphrontRequestExceptionHandler::getAllHandlers();
 
     $request = $this->getRequest();
     foreach ($handlers as $handler) {
       if ($handler->canHandleRequestThrowable($request, $throwable)) {
         $response = $handler->handleRequestThrowable($request, $throwable);
         $this->validateErrorHandlerResponse($handler, $response);
         return $response;
       }
     }
 
     throw $throwable;
   }
 
   private static function newSelfCheckResponse() {
     $path = PhabricatorStartup::getRequestPath();
     $query = idx($_SERVER, 'QUERY_STRING', '');
 
     $pairs = id(new PhutilQueryStringParser())
       ->parseQueryStringToPairList($query);
 
     $params = array();
     foreach ($pairs as $v) {
       $params[] = array(
         'name' => $v[0],
         'value' => $v[1],
       );
     }
 
     $raw_input = @file_get_contents('php://input');
     if ($raw_input !== false) {
       $base64_input = base64_encode($raw_input);
     } else {
       $base64_input = null;
     }
 
     $result = array(
       'path' => $path,
       'params' => $params,
       'user' => idx($_SERVER, 'PHP_AUTH_USER'),
       'pass' => idx($_SERVER, 'PHP_AUTH_PW'),
 
       'raw.base64' => $base64_input,
 
       // This just makes sure that the response compresses well, so reasonable
       // algorithms should want to gzip or deflate it.
       'filler' => str_repeat('Q', 1024 * 16),
     );
 
     return id(new AphrontJSONResponse())
       ->setAddJSONShield(false)
       ->setContent($result);
   }
 
   private static function readHTTPPOSTData() {
     $request_method = idx($_SERVER, 'REQUEST_METHOD');
     if ($request_method === 'PUT') {
       // For PUT requests, do nothing: in particular, do NOT read input. This
       // allows us to stream input later and process very large PUT requests,
       // like those coming from Git LFS.
       return;
     }
 
 
     // For POST requests, we're going to read the raw input ourselves here
     // if we can. Among other things, this corrects variable names with
     // the "." character in them, which PHP normally converts into "_".
 
     // If "enable_post_data_reading" is on, the documentation suggests we
     // can not read the body. In practice, we seem to be able to. This may
     // need to be resolved at some point, likely by instructing installs
     // to disable this option.
 
     // If the content type is "multipart/form-data", we need to build both
     // $_POST and $_FILES, which is involved. The body itself is also more
     // difficult to parse than other requests.
 
     $raw_input = PhabricatorStartup::getRawInput();
     $parser = new PhutilQueryStringParser();
 
     if (phutil_nonempty_string($raw_input)) {
       $content_type = idx($_SERVER, 'CONTENT_TYPE');
       $is_multipart = preg_match('@^multipart/form-data@i', $content_type);
       if ($is_multipart) {
         $multipart_parser = id(new AphrontMultipartParser())
           ->setContentType($content_type);
 
         $multipart_parser->beginParse();
         $multipart_parser->continueParse($raw_input);
         $parts = $multipart_parser->endParse();
 
         // We're building and then parsing a query string so that requests
         // with arrays (like "x[]=apple&x[]=banana") work correctly. This also
         // means we can't use "phutil_build_http_querystring()", since it
         // can't build a query string with duplicate names.
 
         $query_string = array();
         foreach ($parts as $part) {
           if (!$part->isVariable()) {
             continue;
           }
 
           $name = $part->getName();
           $value = $part->getVariableValue();
           $query_string[] = rawurlencode($name).'='.rawurlencode($value);
         }
         $query_string = implode('&', $query_string);
         $post = $parser->parseQueryString($query_string);
 
         $files = array();
         foreach ($parts as $part) {
           if ($part->isVariable()) {
             continue;
           }
 
           $files[$part->getName()] = $part->getPHPFileDictionary();
         }
         $_FILES = $files;
       } else {
         $post = $parser->parseQueryString($raw_input);
       }
 
       $_POST = $post;
       PhabricatorStartup::rebuildRequest();
     } else if ($_POST) {
       $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW);
       if (is_array($post)) {
         $_POST = $post;
         PhabricatorStartup::rebuildRequest();
       }
     }
   }
 
 }
diff --git a/src/aphront/httpparametertype/AphrontHTTPParameterType.php b/src/aphront/httpparametertype/AphrontHTTPParameterType.php
index 78a62a663c..a31101a9fc 100644
--- a/src/aphront/httpparametertype/AphrontHTTPParameterType.php
+++ b/src/aphront/httpparametertype/AphrontHTTPParameterType.php
@@ -1,309 +1,309 @@
 <?php
 
 /**
  * Defines how to read a complex value from an HTTP request.
  *
  * Most HTTP parameters are simple (like strings or integers) but some
  * parameters accept more complex values (like lists of users or project names).
  *
  * This class handles reading simple and complex values from a request,
  * performing any required parsing or lookups, and returning a result in a
  * standard format.
  *
  * @task read Reading Values from a Request
  * @task info Information About the Type
  * @task util Parsing Utilities
  * @task impl Implementation
  */
 abstract class AphrontHTTPParameterType extends Phobject {
 
 
   private $viewer;
 
 
 /* -(  Reading Values from a Request  )-------------------------------------- */
 
 
   /**
    * Set the current viewer.
    *
    * Some parameter types perform complex parsing involving lookups. For
    * example, a type might lookup usernames or project names. These types need
    * to use the current viewer to execute queries.
    *
-   * @param PhabricatorUser Current viewer.
+   * @param PhabricatorUser $viewer Current viewer.
    * @return this
    * @task read
    */
   final public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
 
   /**
    * Get the current viewer.
    *
    * @return PhabricatorUser Current viewer.
    * @task read
    */
   final public function getViewer() {
     if (!$this->viewer) {
       throw new PhutilInvalidStateException('setViewer');
     }
     return $this->viewer;
   }
 
 
   /**
    * Test if a value is present in a request.
    *
-   * @param AphrontRequest The incoming request.
-   * @param string The key to examine.
+   * @param AphrontRequest $request The incoming request.
+   * @param string $key The key to examine.
    * @return bool True if a readable value is present in the request.
    * @task read
    */
   final public function getExists(AphrontRequest $request, $key) {
     return $this->getParameterExists($request, $key);
   }
 
 
   /**
    * Read a value from a request.
    *
    * If the value is not present, a default value is returned (usually `null`).
    * Use @{method:getExists} to test if a value is present.
    *
-   * @param AphrontRequest The incoming request.
-   * @param string The key to examine.
+   * @param AphrontRequest $request The incoming request.
+   * @param string $key The key to examine.
    * @return wild Value, or default if value is not present.
    * @task read
    */
   final public function getValue(AphrontRequest $request, $key) {
 
     if (!$this->getExists($request, $key)) {
       return $this->getParameterDefault();
     }
 
     return $this->getParameterValue($request, $key);
   }
 
 
   /**
    * Get the default value for this parameter type.
    *
    * @return wild Default value for this type.
    * @task read
    */
   final public function getDefaultValue() {
     return $this->getParameterDefault();
   }
 
 
 /* -(  Information About the Type  )----------------------------------------- */
 
 
   /**
    * Get a short name for this type, like `string` or `list<phid>`.
    *
    * @return string Short type name.
    * @task info
    */
   final public function getTypeName() {
     return $this->getParameterTypeName();
   }
 
 
   /**
    * Get a list of human-readable descriptions of acceptable formats for this
    * type.
    *
    * For example, a type might return strings like these:
    *
    * > Any positive integer.
    * > A comma-separated list of PHIDs.
    *
    * This is used to explain to users how to specify a type when generating
    * documentation.
    *
    * @return list<string> Human-readable list of acceptable formats.
    * @task info
    */
   final public function getFormatDescriptions() {
     return $this->getParameterFormatDescriptions();
   }
 
 
   /**
    * Get a list of human-readable examples of how to format this type as an
    * HTTP GET parameter.
    *
    * For example, a type might return strings like these:
    *
    * > v=123
    * > v[]=1&v[]=2
    *
    * This is used to show users how to specify parameters of this type in
    * generated documentation.
    *
    * @return list<string> Human-readable list of format examples.
    * @task info
    */
   final public function getExamples() {
     return $this->getParameterExamples();
   }
 
 
 /* -(  Utilities  )---------------------------------------------------------- */
 
 
   /**
    * Call another type's existence check.
    *
    * This method allows a type to reuse the existence behavior of a different
    * type. For example, a "list of users" type may have the same basic
    * existence check that a simpler "list of strings" type has, and can just
    * call the simpler type to reuse its behavior.
    *
-   * @param AphrontHTTPParameterType The other type.
-   * @param AphrontRequest Incoming request.
-   * @param string Key to examine.
+   * @param AphrontHTTPParameterType $type The other type.
+   * @param AphrontRequest $request Incoming request.
+   * @param string $key Key to examine.
    * @return bool True if the parameter exists.
    * @task util
    */
   final protected function getExistsWithType(
     AphrontHTTPParameterType $type,
     AphrontRequest $request,
     $key) {
 
     $type->setViewer($this->getViewer());
 
     return $type->getParameterExists($request, $key);
   }
 
 
   /**
    * Call another type's value parser.
    *
    * This method allows a type to reuse the parsing behavior of a different
    * type. For example, a "list of users" type may start by running the same
    * basic parsing that a simpler "list of strings" type does.
    *
-   * @param AphrontHTTPParameterType The other type.
-   * @param AphrontRequest Incoming request.
-   * @param string Key to examine.
+   * @param AphrontHTTPParameterType $type The other type.
+   * @param AphrontRequest $request Incoming request.
+   * @param string $key Key to examine.
    * @return wild Parsed value.
    * @task util
    */
   final protected function getValueWithType(
     AphrontHTTPParameterType $type,
     AphrontRequest $request,
     $key) {
 
     $type->setViewer($this->getViewer());
 
     return $type->getValue($request, $key);
   }
 
 
   /**
    * Get a list of all available parameter types.
    *
    * @return list<AphrontHTTPParameterType> List of all available types.
    * @task util
    */
   final public static function getAllTypes() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getTypeName')
       ->setSortMethod('getTypeName')
       ->execute();
   }
 
 
 /* -(  Implementation  )----------------------------------------------------- */
 
 
   /**
    * Test if a parameter exists in a request.
    *
    * See @{method:getExists}. By default, this method tests if the key is
    * present in the request.
    *
    * To call another type's behavior in order to perform this check, use
    * @{method:getExistsWithType}.
    *
-   * @param AphrontRequest The incoming request.
-   * @param string The key to examine.
+   * @param AphrontRequest $request The incoming request.
+   * @param string $key The key to examine.
    * @return bool True if a readable value is present in the request.
    * @task impl
    */
   protected function getParameterExists(AphrontRequest $request, $key) {
     return $request->getExists($key);
   }
 
 
   /**
    * Parse a value from a request.
    *
    * See @{method:getValue}. This method will //only// be called if this type
    * has already asserted that the value exists with
    * @{method:getParameterExists}.
    *
    * To call another type's behavior in order to parse a value, use
    * @{method:getValueWithType}.
    *
-   * @param AphrontRequest The incoming request.
-   * @param string The key to examine.
+   * @param AphrontRequest $request The incoming request.
+   * @param string $key The key to examine.
    * @return wild Parsed value.
    * @task impl
    */
   abstract protected function getParameterValue(AphrontRequest $request, $key);
 
 
   /**
    * Return a simple type name string, like "string" or "list<phid>".
    *
    * See @{method:getTypeName}.
    *
    * @return string Short type name.
    * @task impl
    */
   abstract protected function getParameterTypeName();
 
 
   /**
    * Return a human-readable list of format descriptions.
    *
    * See @{method:getFormatDescriptions}.
    *
    * @return list<string> Human-readable list of acceptable formats.
    * @task impl
    */
   abstract protected function getParameterFormatDescriptions();
 
 
   /**
    * Return a human-readable list of examples.
    *
    * See @{method:getExamples}.
    *
    * @return list<string> Human-readable list of format examples.
    * @task impl
    */
   abstract protected function getParameterExamples();
 
 
   /**
    * Return the default value for this parameter type.
    *
    * See @{method:getDefaultValue}. If unspecified, the default is `null`.
    *
    * @return wild Default value.
    * @task impl
    */
   protected function getParameterDefault() {
     return null;
   }
 
 }
diff --git a/src/aphront/response/AphrontFileResponse.php b/src/aphront/response/AphrontFileResponse.php
index 6508ad6020..522b111a04 100644
--- a/src/aphront/response/AphrontFileResponse.php
+++ b/src/aphront/response/AphrontFileResponse.php
@@ -1,192 +1,192 @@
 <?php
 
 final class AphrontFileResponse extends AphrontResponse {
 
   private $content;
   private $contentIterator;
   private $contentLength;
   private $compressResponse;
 
   private $mimeType;
 
   /**
    * Download filename
    *
    * This is NULL as default or a string.
    *
    * @var string|null
    */
   private $download;
 
   private $rangeMin;
   private $rangeMax;
   private $allowOrigins = array();
 
   public function addAllowOrigin($origin) {
     $this->allowOrigins[] = $origin;
     return $this;
   }
 
   /**
    * Set a download filename
    *
-   * @param $download string
+   * @param string $download
    * @return self
    */
   public function setDownload($download) {
 
     // Make sure we have a populated string
     if (!phutil_nonempty_string($download)) {
       $download = 'untitled';
     }
 
     $this->download = $download;
     return $this;
   }
 
   /**
    * Get the download filename
    *
    * If this was never set, NULL is given.
    *
    * @return string|null
    */
   public function getDownload() {
     return $this->download;
   }
 
   public function setMimeType($mime_type) {
     $this->mimeType = $mime_type;
     return $this;
   }
 
   public function getMimeType() {
     return $this->mimeType;
   }
 
   public function setContent($content) {
     $this->setContentLength(strlen($content));
     $this->content = $content;
     return $this;
   }
 
   public function setContentIterator($iterator) {
     $this->contentIterator = $iterator;
     return $this;
   }
 
   public function buildResponseString() {
     return $this->content;
   }
 
   public function getContentIterator() {
     if ($this->contentIterator) {
       return $this->contentIterator;
     }
     return parent::getContentIterator();
   }
 
   public function setContentLength($length) {
     $this->contentLength = $length;
     return $this;
   }
 
   public function getContentLength() {
     return $this->contentLength;
   }
 
   public function setCompressResponse($compress_response) {
     $this->compressResponse = $compress_response;
     return $this;
   }
 
   public function getCompressResponse() {
     return $this->compressResponse;
   }
 
   public function setRange($min, $max) {
     $this->rangeMin = $min;
     $this->rangeMax = $max;
     return $this;
   }
 
   public function getHeaders() {
     $headers = array(
       array('Content-Type', $this->getMimeType()),
       // This tells clients that we can support requests with a "Range" header,
       // which allows downloads to be resumed, in some browsers, some of the
       // time, if the stars align.
       array('Accept-Ranges', 'bytes'),
     );
 
     if ($this->rangeMin !== null || $this->rangeMax !== null) {
       $len = $this->getContentLength();
       $min = $this->rangeMin;
 
       $max = $this->rangeMax;
       if ($max === null) {
         $max = ($len - 1);
       }
 
       $headers[] = array('Content-Range', "bytes {$min}-{$max}/{$len}");
       $content_len = ($max - $min) + 1;
     } else {
       $content_len = $this->getContentLength();
     }
 
     if (!$this->shouldCompressResponse()) {
       $headers[] = array('Content-Length', $content_len);
     }
 
     if (phutil_nonempty_string($this->getDownload())) {
       $headers[] = array('X-Download-Options', 'noopen');
 
       $filename = $this->getDownload();
       $filename = addcslashes($filename, '"\\');
       $headers[] = array(
         'Content-Disposition',
         'attachment; filename="'.$filename.'"',
       );
     }
 
     if ($this->allowOrigins) {
       $headers[] = array(
         'Access-Control-Allow-Origin',
         implode(',', $this->allowOrigins),
       );
     }
 
     $headers = array_merge(parent::getHeaders(), $headers);
     return $headers;
   }
 
   protected function shouldCompressResponse() {
     return $this->getCompressResponse();
   }
 
   public function parseHTTPRange($range) {
     $begin = null;
     $end = null;
 
     $matches = null;
     if (preg_match('/^bytes=(\d+)-(\d*)$/', $range, $matches)) {
       // Note that the "Range" header specifies bytes differently than
       // we do internally: the range 0-1 has 2 bytes (byte 0 and byte 1).
       $begin = (int)$matches[1];
 
       // The "Range" may be "200-299" or "200-", meaning "until end of file".
       if (strlen($matches[2])) {
         $range_end = (int)$matches[2];
         $end = $range_end + 1;
       } else {
         $range_end = null;
       }
 
       $this->setHTTPResponseCode(206);
       $this->setRange($begin, $range_end);
     }
 
     return array($begin, $end);
   }
 
 }
diff --git a/src/aphront/response/AphrontRedirectResponse.php b/src/aphront/response/AphrontRedirectResponse.php
index 390ad193c9..5494d3e0e3 100644
--- a/src/aphront/response/AphrontRedirectResponse.php
+++ b/src/aphront/response/AphrontRedirectResponse.php
@@ -1,176 +1,177 @@
 <?php
 
 /**
  * TODO: Should be final but isn't because of AphrontReloadResponse.
  */
 class AphrontRedirectResponse extends AphrontResponse {
 
   private $uri;
   private $stackWhenCreated;
   private $isExternal;
   private $closeDialogBeforeRedirect;
 
   public function setIsExternal($external) {
     $this->isExternal = $external;
     return $this;
   }
 
   public function __construct() {
     if ($this->shouldStopForDebugging()) {
       // If we're going to stop, capture the stack so we can print it out.
       $this->stackWhenCreated = id(new Exception())->getTrace();
     }
   }
 
   public function setURI($uri) {
     $this->uri = $uri;
     return $this;
   }
 
   public function getURI() {
     // NOTE: When we convert a RedirectResponse into an AjaxResponse, we pull
     // the URI through this method. Make sure it passes checks before we
     // hand it over to callers.
     return self::getURIForRedirect($this->uri, $this->isExternal);
   }
 
   public function shouldStopForDebugging() {
     return PhabricatorEnv::getEnvConfig('debug.stop-on-redirect');
   }
 
   public function setCloseDialogBeforeRedirect($close) {
     $this->closeDialogBeforeRedirect = $close;
     return $this;
   }
 
   public function getCloseDialogBeforeRedirect() {
     return $this->closeDialogBeforeRedirect;
   }
 
   public function getHeaders() {
     $headers = array();
     if (!$this->shouldStopForDebugging()) {
       $uri = self::getURIForRedirect($this->uri, $this->isExternal);
       $headers[] = array('Location', $uri);
     }
     $headers = array_merge(parent::getHeaders(), $headers);
     return $headers;
   }
 
   public function buildResponseString() {
     if ($this->shouldStopForDebugging()) {
       $request = $this->getRequest();
       $viewer = $request->getUser();
 
       $view = new PhabricatorStandardPageView();
       $view->setRequest($this->getRequest());
       $view->setApplicationName(pht('Debug'));
       $view->setTitle(pht('Stopped on Redirect'));
 
       $dialog = new AphrontDialogView();
       $dialog->setUser($viewer);
       $dialog->setTitle(pht('Stopped on Redirect'));
 
       $dialog->appendParagraph(
         pht(
           'You were stopped here because %s is set in your configuration.',
           phutil_tag('tt', array(), 'debug.stop-on-redirect')));
 
       $dialog->appendParagraph(
         pht(
           'You are being redirected to: %s',
           phutil_tag('tt', array(), $this->getURI())));
 
       $dialog->addCancelButton($this->getURI(), pht('Continue'));
 
       $dialog->appendChild(phutil_tag('br'));
 
       $dialog->appendChild(
         id(new AphrontStackTraceView())
           ->setUser($viewer)
           ->setTrace($this->stackWhenCreated));
 
       $dialog->setIsStandalone(true);
       $dialog->setWidth(AphrontDialogView::WIDTH_FULL);
 
       $box = id(new PHUIBoxView())
         ->addMargin(PHUI::MARGIN_LARGE)
         ->appendChild($dialog);
 
       $view->appendChild($box);
 
       return $view->render();
     }
 
     return '';
   }
 
 
   /**
    * Format a URI for use in a "Location:" header.
    *
    * Verifies that a URI redirects to the expected type of resource (local or
    * remote) and formats it for use in a "Location:" header.
    *
    * The HTTP spec says "Location:" headers must use absolute URIs. Although
    * browsers work with relative URIs, we return absolute URIs to avoid
    * ambiguity. For example, Chrome interprets "Location: /\evil.com" to mean
    * "perform a protocol-relative redirect to evil.com".
    *
-   * @param   string  URI to redirect to.
-   * @param   bool    True if this URI identifies a remote resource.
+   * @param   string  $uri URI to redirect to.
+   * @param   bool    $is_external True if this URI identifies a remote
+   *   resource.
    * @return  string  URI for use in a "Location:" header.
    */
   public static function getURIForRedirect($uri, $is_external) {
     $uri_object = new PhutilURI($uri);
     if ($is_external) {
       // If this is a remote resource it must have a domain set. This
       // would also be caught below, but testing for it explicitly first allows
       // us to raise a better error message.
       if (!strlen($uri_object->getDomain())) {
         throw new Exception(
           pht(
             'Refusing to redirect to external URI "%s". This URI '.
             'is not fully qualified, and is missing a domain name. To '.
             'redirect to a local resource, remove the external flag.',
             (string)$uri));
       }
 
       // Check that it's a valid remote resource.
       if (!PhabricatorEnv::isValidURIForLink($uri)) {
         throw new Exception(
           pht(
             'Refusing to redirect to external URI "%s". This URI '.
             'is not a valid remote web resource.',
             (string)$uri));
       }
     } else {
       // If this is a local resource, it must not have a domain set. This allows
       // us to raise a better error message than the check below can.
       if (strlen($uri_object->getDomain())) {
         throw new Exception(
           pht(
             'Refusing to redirect to local resource "%s". The URI has a '.
             'domain, but the redirect is not marked external. Mark '.
             'redirects as external to allow redirection off the local '.
             'domain.',
             (string)$uri));
       }
 
       // If this is a local resource, it must be a valid local resource.
       if (!PhabricatorEnv::isValidLocalURIForLink($uri)) {
         throw new Exception(
           pht(
             'Refusing to redirect to local resource "%s". This URI is not '.
             'formatted in a recognizable way.',
             (string)$uri));
       }
 
       // Fully qualify the result URI.
       $uri = PhabricatorEnv::getURI((string)$uri);
     }
 
     return (string)$uri;
   }
 
 }
diff --git a/src/aphront/sink/AphrontHTTPSink.php b/src/aphront/sink/AphrontHTTPSink.php
index 9e43e4a687..f2a2f50860 100644
--- a/src/aphront/sink/AphrontHTTPSink.php
+++ b/src/aphront/sink/AphrontHTTPSink.php
@@ -1,172 +1,173 @@
 <?php
 
 /**
  * Abstract class which wraps some sort of output mechanism for HTTP responses.
  * Normally this is just @{class:AphrontPHPHTTPSink}, which uses "echo" and
  * "header()" to emit responses.
  *
  * @task write  Writing Response Components
  * @task emit   Emitting the Response
  */
 abstract class AphrontHTTPSink extends Phobject {
 
   private $showStackTraces = false;
 
   final public function setShowStackTraces($show_stack_traces) {
     $this->showStackTraces = $show_stack_traces;
     return $this;
   }
 
   final public function getShowStackTraces() {
     return $this->showStackTraces;
   }
 
 
 /* -(  Writing Response Components  )---------------------------------------- */
 
 
   /**
    * Write an HTTP status code to the output.
    *
-   * @param int Numeric HTTP status code.
+   * @param int $code Numeric HTTP status code.
+   * @param string? $message
    * @return void
    */
   final public function writeHTTPStatus($code, $message = '') {
     if (!preg_match('/^\d{3}$/', $code)) {
       throw new Exception(pht("Malformed HTTP status code '%s'!", $code));
     }
 
     $code = (int)$code;
     $this->emitHTTPStatus($code, $message);
   }
 
 
   /**
    * Write HTTP headers to the output.
    *
-   * @param list<pair> List of <name, value> pairs.
+   * @param list<pair> $headers List of <name, value> pairs.
    * @return void
    */
   final public function writeHeaders(array $headers) {
     foreach ($headers as $header) {
       if (!is_array($header) || count($header) !== 2) {
         throw new Exception(pht('Malformed header.'));
       }
       list($name, $value) = $header;
 
       if (strpos($name, ':') !== false) {
         throw new Exception(
           pht(
             'Declining to emit response with malformed HTTP header name: %s',
             $name));
       }
 
       // Attackers may perform an "HTTP response splitting" attack by making
       // the application emit certain types of headers containing newlines:
       //
       //   http://en.wikipedia.org/wiki/HTTP_response_splitting
       //
       // PHP has built-in protections against HTTP response-splitting, but they
       // are of dubious trustworthiness:
       //
       //   http://news.php.net/php.internals/57655
 
       if (preg_match('/[\r\n\0]/', $name.$value)) {
         throw new Exception(
           pht(
             'Declining to emit response with unsafe HTTP header: %s',
             "<'".$name."', '".$value."'>."));
       }
     }
 
     foreach ($headers as $header) {
       list($name, $value) = $header;
       $this->emitHeader($name, $value);
     }
   }
 
 
   /**
    * Write HTTP body data to the output.
    *
-   * @param string Body data.
+   * @param string $data Body data.
    * @return void
    */
   final public function writeData($data) {
     $this->emitData($data);
   }
 
 
   /**
    * Write an entire @{class:AphrontResponse} to the output.
    *
-   * @param AphrontResponse The response object to write.
+   * @param AphrontResponse $response The response object to write.
    * @return void
    */
   final public function writeResponse(AphrontResponse $response) {
     $response->willBeginWrite();
 
     // Build the content iterator first, in case it throws. Ideally, we'd
     // prefer to handle exceptions before we emit the response status or any
     // HTTP headers.
     $data = $response->getContentIterator();
 
     // This isn't an exceptionally clean separation of concerns, but we need
     // to add CSP headers for all response types (including both web pages
     // and dialogs) and can't determine the correct CSP until after we render
     // the page (because page elements like Recaptcha may add CSP rules).
     $static = CelerityAPI::getStaticResourceResponse();
     foreach ($static->getContentSecurityPolicyURIMap() as $kind => $uris) {
       foreach ($uris as $uri) {
         $response->addContentSecurityPolicyURI($kind, $uri);
       }
     }
 
     $all_headers = array_merge(
       $response->getHeaders(),
       $response->getCacheHeaders());
 
     $this->writeHTTPStatus(
       $response->getHTTPResponseCode(),
       $response->getHTTPResponseMessage());
     $this->writeHeaders($all_headers);
 
     // Allow clients an unlimited amount of time to download the response.
 
     // This allows clients to perform a "slow loris" attack, where they
     // download a large response very slowly to tie up process slots. However,
     // concurrent connection limits and "RequestReadTimeout" already prevent
     // this attack. We could add our own minimum download rate here if we want
     // to make this easier to configure eventually.
 
     // For normal page responses, we've fully rendered the page into a string
     // already so all that's left is writing it to the client.
 
     // For unusual responses (like large file downloads) we may still be doing
     // some meaningful work, but in theory that work is intrinsic to streaming
     // the response.
 
     set_time_limit(0);
 
     $abort = false;
     foreach ($data as $block) {
       if (!$this->isWritable()) {
         $abort = true;
         break;
       }
       $this->writeData($block);
     }
 
     $response->didCompleteWrite($abort);
   }
 
 
 /* -(  Emitting the Response  )---------------------------------------------- */
 
 
   abstract protected function emitHTTPStatus($code, $message = '');
   abstract protected function emitHeader($name, $value);
   abstract protected function emitData($data);
   abstract protected function isWritable();
 
 }
diff --git a/src/aphront/site/AphrontRoutingMap.php b/src/aphront/site/AphrontRoutingMap.php
index bda98429f9..687aae93bb 100644
--- a/src/aphront/site/AphrontRoutingMap.php
+++ b/src/aphront/site/AphrontRoutingMap.php
@@ -1,159 +1,159 @@
 <?php
 
 /**
  * Collection of routes on a site for an application.
  *
  * @task info Map Information
  * @task routing Routing
  */
 final class AphrontRoutingMap extends Phobject {
 
   private $site;
   private $application;
   private $routes = array();
 
 
 /* -(  Map Info  )----------------------------------------------------------- */
 
 
   public function setSite(AphrontSite $site) {
     $this->site = $site;
     return $this;
   }
 
   public function getSite() {
     return $this->site;
   }
 
   public function setApplication(PhabricatorApplication $application) {
     $this->application = $application;
     return $this;
   }
 
   public function getApplication() {
     return $this->application;
   }
 
   public function setRoutes(array $routes) {
     $this->routes = $routes;
     return $this;
   }
 
   public function getRoutes() {
     return $this->routes;
   }
 
 
 /* -(  Routing  )------------------------------------------------------------ */
 
 
   /**
    * Find the route matching a path, if one exists.
    *
-   * @param string Path to route.
+   * @param string $path Path to route.
    * @return AphrontRoutingResult|null Routing result, if path matches map.
    * @task routing
    */
   public function routePath($path) {
     $map = $this->getRoutes();
 
     foreach ($map as $route => $value) {
       $match = $this->tryRoute($route, $value, $path);
       if (!$match) {
         continue;
       }
 
       $result = $this->newRoutingResult();
       $application = $result->getApplication();
 
       $controller_class = $match['class'];
       $controller = newv($controller_class, array());
       $controller->setCurrentApplication($application);
 
       $result
         ->setController($controller)
         ->setURIData($match['data']);
 
       return $result;
     }
 
     return null;
   }
 
 
   /**
    * Test a sub-map to see if any routes match a path.
    *
-   * @param string Path to route.
-   * @param string Pattern from the map.
-   * @param string Value from the map.
+   * @param string $route Pattern from the map.
+   * @param string $value Value from the map.
+   * @param string $path Path to route.
    * @return dict<string, wild>|null Match details, if path matches sub-map.
    * @task routing
    */
   private function tryRoute($route, $value, $path) {
     $has_submap = is_array($value);
 
     if (!$has_submap) {
       // If the value is a controller rather than a sub-map, any matching
       // route must completely consume the path.
       $pattern = '(^'.$route.'\z)';
     } else {
       $pattern = '(^'.$route.')';
     }
 
     $data = null;
     $ok = preg_match($pattern, $path, $data);
     if ($ok === false) {
       throw new Exception(
         pht(
           'Routing fragment "%s" is not a valid regular expression.',
           $route));
     }
 
     if (!$ok) {
       return null;
     }
 
     $path_match = $data[0];
 
     // Clean up the data. We only want to retain named capturing groups, not
     // the duplicated numeric captures.
     foreach ($data as $k => $v) {
       if (is_numeric($k)) {
         unset($data[$k]);
       }
     }
 
     if (!$has_submap) {
       return array(
         'class' => $value,
         'data' => $data,
       );
     }
 
     $sub_path = substr($path, strlen($path_match));
     foreach ($value as $sub_route => $sub_value) {
       $result = $this->tryRoute($sub_route, $sub_value, $sub_path);
       if ($result) {
         $result['data'] += $data;
         return $result;
       }
     }
 
     return null;
   }
 
 
   /**
    * Build a new routing result for this map.
    *
    * @return AphrontRoutingResult New, empty routing result.
    * @task routing
    */
   private function newRoutingResult() {
     return id(new AphrontRoutingResult())
       ->setSite($this->getSite())
       ->setApplication($this->getApplication());
   }
 
 }
diff --git a/src/aphront/writeguard/AphrontWriteGuard.php b/src/aphront/writeguard/AphrontWriteGuard.php
index 589a0db37b..6a3d053cf1 100644
--- a/src/aphront/writeguard/AphrontWriteGuard.php
+++ b/src/aphront/writeguard/AphrontWriteGuard.php
@@ -1,267 +1,267 @@
 <?php
 
 /**
  * Guard writes against CSRF. The Aphront structure takes care of most of this
  * for you, you just need to call:
  *
  *    AphrontWriteGuard::willWrite();
  *
  * ...before executing a write against any new kind of storage engine. MySQL
  * databases and the default file storage engines are already covered, but if
  * you introduce new types of datastores make sure their writes are guarded. If
  * you don't guard writes and make a mistake doing CSRF checks in a controller,
  * a CSRF vulnerability can escape undetected.
  *
  * If you need to execute writes on a page which doesn't have CSRF tokens (for
  * example, because you need to do logging), you can temporarily disable the
  * write guard by calling:
  *
  *    AphrontWriteGuard::beginUnguardedWrites();
  *    do_logging_write();
  *    AphrontWriteGuard::endUnguardedWrites();
  *
  * This is dangerous, because it disables the backup layer of CSRF protection
  * this class provides. You should need this only very, very rarely.
  *
  * @task protect  Protecting Writes
  * @task disable  Disabling Protection
  * @task manage   Managing Write Guards
  * @task internal Internals
  */
 final class AphrontWriteGuard extends Phobject {
 
   private static $instance;
   private static $allowUnguardedWrites = false;
 
   private $callback;
   private $allowDepth = 0;
 
 
 /* -(  Managing Write Guards  )---------------------------------------------- */
 
 
   /**
    * Construct a new write guard for a request. Only one write guard may be
    * active at a time. You must explicitly call @{method:dispose} when you are
    * done with a write guard:
    *
    *    $guard = new AphrontWriteGuard($callback);
    *    // ...
    *    $guard->dispose();
    *
    * Normally, you do not need to manage guards yourself -- the Aphront stack
    * handles it for you.
    *
    * This class accepts a callback, which will be invoked when a write is
    * attempted. The callback should validate the presence of a CSRF token in
    * the request, or abort the request (e.g., by throwing an exception) if a
    * valid token isn't present.
    *
-   * @param   callable CSRF callback.
+   * @param   $callback Callable CSRF callback.
    * @return  this
    * @task    manage
    */
   public function __construct($callback) {
     if (self::$instance) {
       throw new Exception(
         pht(
           'An %s already exists. Dispose of the previous guard '.
           'before creating a new one.',
           __CLASS__));
     }
     if (self::$allowUnguardedWrites) {
       throw new Exception(
         pht(
           'An %s is being created in a context which permits '.
           'unguarded writes unconditionally. This is not allowed and '.
           'indicates a serious error.',
           __CLASS__));
     }
     $this->callback = $callback;
     self::$instance = $this;
   }
 
 
   /**
    * Dispose of the active write guard. You must call this method when you are
    * done with a write guard. You do not normally need to call this yourself.
    *
    * @return void
    * @task manage
    */
   public function dispose() {
     if (!self::$instance) {
       throw new Exception(pht(
         'Attempting to dispose of write guard, but no write guard is active!'));
     }
 
     if ($this->allowDepth > 0) {
       throw new Exception(
         pht(
           'Imbalanced %s: more %s calls than %s calls.',
           __CLASS__,
           'beginUnguardedWrites()',
           'endUnguardedWrites()'));
     }
     self::$instance = null;
   }
 
 
   /**
    * Determine if there is an active write guard.
    *
    * @return bool
    * @task manage
    */
   public static function isGuardActive() {
     return (bool)self::$instance;
   }
 
   /**
    * Return on instance of AphrontWriteGuard if it's active, or null
    *
    * @return AphrontWriteGuard|null
    */
   public static function getInstance() {
     return self::$instance;
   }
 
 
 /* -(  Protecting Writes  )-------------------------------------------------- */
 
 
   /**
    * Declare intention to perform a write, validating that writes are allowed.
    * You should call this method before executing a write whenever you implement
    * a new storage engine where information can be permanently kept.
    *
    * Writes are permitted if:
    *
    *   - The request has valid CSRF tokens.
    *   - Unguarded writes have been temporarily enabled by a call to
    *     @{method:beginUnguardedWrites}.
    *   - All write guarding has been disabled with
    *     @{method:allowDangerousUnguardedWrites}.
    *
    * If none of these conditions are true, this method will throw and prevent
    * the write.
    *
    * @return void
    * @task protect
    */
   public static function willWrite() {
     if (!self::$instance) {
       if (!self::$allowUnguardedWrites) {
         throw new Exception(
           pht(
             'Unguarded write! There must be an active %s to perform writes.',
             __CLASS__));
       } else {
         // Unguarded writes are being allowed unconditionally.
         return;
       }
     }
 
     $instance = self::$instance;
     if ($instance->allowDepth == 0) {
       call_user_func($instance->callback);
     }
   }
 
 
 /* -(  Disabling Write Protection  )----------------------------------------- */
 
 
   /**
    * Enter a scope which permits unguarded writes. This works like
    * @{method:beginUnguardedWrites} but returns an object which will end
    * the unguarded write scope when its __destruct() method is called. This
    * is useful to more easily handle exceptions correctly in unguarded write
    * blocks:
    *
    *   // Restores the guard even if do_logging() throws.
    *   function unguarded_scope() {
    *     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
    *     do_logging();
    *   }
    *
    * @return AphrontScopedUnguardedWriteCapability Object which ends unguarded
    *            writes when it leaves scope.
    * @task disable
    */
   public static function beginScopedUnguardedWrites() {
     self::beginUnguardedWrites();
     return new AphrontScopedUnguardedWriteCapability();
   }
 
 
   /**
    * Begin a block which permits unguarded writes. You should use this very
    * sparingly, and only for things like logging where CSRF is not a concern.
    *
    * You must pair every call to @{method:beginUnguardedWrites} with a call to
    * @{method:endUnguardedWrites}:
    *
    *   AphrontWriteGuard::beginUnguardedWrites();
    *   do_logging();
    *   AphrontWriteGuard::endUnguardedWrites();
    *
    * @return void
    * @task disable
    */
   public static function beginUnguardedWrites() {
     if (!self::$instance) {
       return;
     }
     self::$instance->allowDepth++;
   }
 
   /**
    * Declare that you have finished performing unguarded writes. You must
    * call this exactly once for each call to @{method:beginUnguardedWrites}.
    *
    * @return void
    * @task disable
    */
   public static function endUnguardedWrites() {
     if (!self::$instance) {
       return;
     }
     if (self::$instance->allowDepth <= 0) {
       throw new Exception(
         pht(
           'Imbalanced %s: more %s calls than %s calls.',
           __CLASS__,
           'endUnguardedWrites()',
           'beginUnguardedWrites()'));
     }
     self::$instance->allowDepth--;
   }
 
 
   /**
    * Allow execution of unguarded writes. This is ONLY appropriate for use in
    * script contexts or other contexts where you are guaranteed to never be
    * vulnerable to CSRF concerns. Calling this method is EXTREMELY DANGEROUS
    * if you do not understand the consequences.
    *
    * If you need to perform unguarded writes on an otherwise guarded workflow
    * which is vulnerable to CSRF, use @{method:beginUnguardedWrites}.
    *
    * @return void
    * @task disable
    */
   public static function allowDangerousUnguardedWrites($allow) {
     if (self::$instance) {
       throw new Exception(
         pht(
           'You can not unconditionally disable %s by calling %s while a write '.
           'guard is active. Use %s to temporarily allow unguarded writes.',
           __CLASS__,
           __FUNCTION__.'()',
           'beginUnguardedWrites()'));
     }
     self::$allowUnguardedWrites = true;
   }
 
 }
diff --git a/src/applications/auth/constants/PhabricatorCommonPasswords.php b/src/applications/auth/constants/PhabricatorCommonPasswords.php
index 1313257553..e74a255b09 100644
--- a/src/applications/auth/constants/PhabricatorCommonPasswords.php
+++ b/src/applications/auth/constants/PhabricatorCommonPasswords.php
@@ -1,70 +1,70 @@
 <?php
 
 /**
  * Check if a password is extremely common. Preventing use of the most common
  * passwords is an attempt to mitigate slow botnet attacks against an entire
  * userbase. See T4143 for discussion.
  *
  * @task common Checking Common Passwords
  */
 final class PhabricatorCommonPasswords extends Phobject {
 
 
 /* -(  Checking Common Passwords  )------------------------------------------ */
 
 
   /**
    * Check if a password is extremely common.
    *
-   * @param   string  Password to test.
+   * @param   string  $password Password to test.
    * @return  bool    True if the password is pathologically weak.
    *
    * @task common
    */
   public static function isCommonPassword($password) {
     static $list;
     if ($list === null) {
       $list = self::loadWordlist();
     }
 
     return isset($list[strtolower($password)]);
   }
 
 
   /**
    * Load the common password wordlist.
    *
    * @return map<string, bool>  Map of common passwords.
    *
    * @task common
    */
   private static function loadWordlist() {
     $root = dirname(phutil_get_library_root('phabricator'));
     $file = $root.'/externals/wordlist/password.lst';
     $data = Filesystem::readFile($file);
 
     $words = phutil_split_lines($data, $retain_endings = false);
 
     $map = array();
     foreach ($words as $key => $word) {
       // The wordlist file has some comments at the top, strip those out.
       if (preg_match('/^#!comment:/', $word)) {
         continue;
       }
       $map[strtolower($word)] = true;
     }
 
     // Add in some application-specific passwords.
     $map += array(
       'phabricator' => true,
       'phab' => true,
       'devtools' => true,
       'differential' => true,
       'codereview' => true,
       'review' => true,
     );
 
     return $map;
   }
 
 }
diff --git a/src/applications/auth/constants/PhabricatorCookies.php b/src/applications/auth/constants/PhabricatorCookies.php
index 9dc9d823b2..607240c97b 100644
--- a/src/applications/auth/constants/PhabricatorCookies.php
+++ b/src/applications/auth/constants/PhabricatorCookies.php
@@ -1,179 +1,179 @@
 <?php
 
 /**
  * Consolidates Phabricator application cookies, including registration
  * and session management.
  *
  * @task clientid   Client ID Cookie
  * @task next       Next URI Cookie
  */
 final class PhabricatorCookies extends Phobject {
 
   /**
    * Stores the login username for password authentication. This is just a
    * display value for convenience, used to prefill the login form. It is not
    * authoritative.
    */
   const COOKIE_USERNAME       = 'phusr';
 
 
   /**
    * Stores the user's current session ID. This is authoritative and establishes
    * the user's identity.
    */
   const COOKIE_SESSION        = 'phsid';
 
 
   /**
    * Stores a secret used during new account registration to prevent an attacker
    * from tricking a victim into registering an account which is linked to
    * credentials the attacker controls.
    */
   const COOKIE_REGISTRATION   = 'phreg';
 
 
   /**
    * Stores a secret used during OAuth2 handshakes to prevent various attacks
    * where an attacker hands a victim a URI corresponding to the middle of an
    * OAuth2 workflow and we might otherwise do something sketchy. Particularly,
    * this corresponds to the OAuth2 "code".
    */
   const COOKIE_CLIENTID       = 'phcid';
 
 
   /**
    * Stores the URI to redirect the user to after login. This allows users to
    * visit a path like `/feed/`, be prompted to login, and then be redirected
    * back to `/feed/` after the workflow completes.
    */
   const COOKIE_NEXTURI        = 'next_uri';
 
 
   /**
    * Stores a hint that the user should be moved directly into high security
    * after upgrading a partial login session. This is used during password
    * recovery to avoid a double-prompt.
    */
   const COOKIE_HISEC          = 'jump_to_hisec';
 
 
   /**
    * Stores an invite code.
    */
   const COOKIE_INVITE = 'invite';
 
 
   /**
    * Stores a workflow completion across a redirect-after-POST following a
    * form submission. This can be used to show "Changes Saved" messages.
    */
   const COOKIE_SUBMIT = 'phfrm';
 
 
 /* -(  Client ID Cookie  )--------------------------------------------------- */
 
 
   /**
    * Set the client ID cookie. This is a random cookie used like a CSRF value
    * during authentication workflows.
    *
-   * @param AphrontRequest  Request to modify.
+   * @param AphrontRequest $request Request to modify.
    * @return void
    * @task clientid
    */
   public static function setClientIDCookie(AphrontRequest $request) {
 
     // NOTE: See T3471 for some discussion. Some browsers and browser extensions
     // can make duplicate requests, so we overwrite this cookie only if it is
     // not present in the request. The cookie lifetime is limited by making it
     // temporary and clearing it when users log out.
 
     $value = $request->getCookie(self::COOKIE_CLIENTID);
     if (!phutil_nonempty_string($value)) {
       $request->setTemporaryCookie(
         self::COOKIE_CLIENTID,
         Filesystem::readRandomCharacters(16));
     }
   }
 
 
 /* -(  Next URI Cookie  )---------------------------------------------------- */
 
 
   /**
    * Set the Next URI cookie. We only write the cookie if it wasn't recently
    * written, to avoid writing over a real URI with a bunch of "humans.txt"
    * stuff. See T3793 for discussion.
    *
-   * @param   AphrontRequest    Request to write to.
-   * @param   string            URI to write.
-   * @param   bool              Write this cookie even if we have a fresh
-   *                            cookie already.
+   * @param   AphrontRequest    $request Request to write to.
+   * @param   string            $next_uri URI to write.
+   * @param   bool?             $force Write this cookie even if we have a
+   *                            fresh cookie already.
    * @return  void
    *
    * @task next
    */
   public static function setNextURICookie(
     AphrontRequest $request,
     $next_uri,
     $force = false) {
 
     if (!$force) {
       $cookie_value = $request->getCookie(self::COOKIE_NEXTURI);
       list($set_at, $current_uri) = self::parseNextURICookie($cookie_value);
 
       // If the cookie was set within the last 2 minutes, don't overwrite it.
       // Primarily, this prevents browser requests for resources which do not
       // exist (like "humans.txt" and various icons) from overwriting a normal
       // URI like "/feed/".
       if ($set_at > (time() - 120)) {
         return;
       }
     }
 
     $new_value = time().','.$next_uri;
     $request->setTemporaryCookie(self::COOKIE_NEXTURI, $new_value);
   }
 
 
   /**
    * Read the URI out of the Next URI cookie.
    *
-   * @param   AphrontRequest  Request to examine.
+   * @param   AphrontRequest  $request Request to examine.
    * @return  string|null     Next URI cookie's URI value.
    *
    * @task next
    */
   public static function getNextURICookie(AphrontRequest $request) {
     $cookie_value = $request->getCookie(self::COOKIE_NEXTURI);
     list($set_at, $next_uri) = self::parseNextURICookie($cookie_value);
 
     return $next_uri;
   }
 
 
   /**
    * Parse a Next URI cookie into its components.
    *
-   * @param   string        Raw cookie value.
+   * @param   string        $cookie Raw cookie value.
    * @return  list<string>  List of timestamp and URI.
    *
    * @task next
    */
   private static function parseNextURICookie($cookie) {
     // Old cookies look like: /uri
     // New cookies look like: timestamp,/uri
 
     if (!phutil_nonempty_string($cookie)) {
       return null;
     }
 
     if (strpos($cookie, ',') !== false) {
       list($timestamp, $uri) = explode(',', $cookie, 2);
       return array((int)$timestamp, $uri);
     }
 
     return array(0, $cookie);
   }
 
 }
diff --git a/src/applications/auth/controller/PhabricatorAuthController.php b/src/applications/auth/controller/PhabricatorAuthController.php
index 8bba90a1e6..bb95c2ec72 100644
--- a/src/applications/auth/controller/PhabricatorAuthController.php
+++ b/src/applications/auth/controller/PhabricatorAuthController.php
@@ -1,299 +1,300 @@
 <?php
 
 abstract class PhabricatorAuthController extends PhabricatorController {
 
   protected function renderErrorPage($title, array $messages) {
     $view = new PHUIInfoView();
     $view->setTitle($title);
     $view->setErrors($messages);
 
     return $this->newPage()
       ->setTitle($title)
       ->appendChild($view);
 
   }
 
   /**
    * Returns true if this install is newly setup (i.e., there are no user
    * accounts yet). In this case, we enter a special mode to permit creation
    * of the first account form the web UI.
    */
   protected function isFirstTimeSetup() {
     // If there are any auth providers, this isn't first time setup, even if
     // we don't have accounts.
     if (PhabricatorAuthProvider::getAllEnabledProviders()) {
       return false;
     }
 
     // Otherwise, check if there are any user accounts. If not, we're in first
     // time setup.
     $any_users = id(new PhabricatorPeopleQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->setLimit(1)
       ->execute();
 
     return !$any_users;
   }
 
 
   /**
    * Log a user into a web session and return an @{class:AphrontResponse} which
    * corresponds to continuing the login process.
    *
    * Normally, this is a redirect to the validation controller which makes sure
    * the user's cookies are set. However, event listeners can intercept this
    * event and do something else if they prefer.
    *
-   * @param   PhabricatorUser   User to log the viewer in as.
-   * @param bool True to issue a full session immediately, bypassing MFA.
+   * @param   PhabricatorUser $user User to log the viewer in as.
+   * @param bool? $force_full_session True to issue a full session immediately,
+   *   bypassing MFA.
    * @return  AphrontResponse   Response which continues the login process.
    */
   protected function loginUser(
     PhabricatorUser $user,
     $force_full_session = false) {
 
     $response = $this->buildLoginValidateResponse($user);
     $session_type = PhabricatorAuthSession::TYPE_WEB;
 
     if ($force_full_session) {
       $partial_session = false;
     } else {
       $partial_session = true;
     }
 
     $session_key = id(new PhabricatorAuthSessionEngine())
       ->establishSession($session_type, $user->getPHID(), $partial_session);
 
     // NOTE: We allow disabled users to login and roadblock them later, so
     // there's no check for users being disabled here.
 
     $request = $this->getRequest();
     $request->setCookie(
       PhabricatorCookies::COOKIE_USERNAME,
       $user->getUsername());
     $request->setCookie(
       PhabricatorCookies::COOKIE_SESSION,
       $session_key);
 
     $this->clearRegistrationCookies();
 
     return $response;
   }
 
   protected function clearRegistrationCookies() {
     $request = $this->getRequest();
 
     // Clear the registration key.
     $request->clearCookie(PhabricatorCookies::COOKIE_REGISTRATION);
 
     // Clear the client ID / OAuth state key.
     $request->clearCookie(PhabricatorCookies::COOKIE_CLIENTID);
 
     // Clear the invite cookie.
     $request->clearCookie(PhabricatorCookies::COOKIE_INVITE);
   }
 
   private function buildLoginValidateResponse(PhabricatorUser $user) {
     $validate_uri = new PhutilURI($this->getApplicationURI('validate/'));
     $validate_uri->replaceQueryParam('expect', $user->getUsername());
 
     return id(new AphrontRedirectResponse())->setURI((string)$validate_uri);
   }
 
   protected function renderError($message) {
     return $this->renderErrorPage(
       pht('Authentication Error'),
       array(
         $message,
       ));
   }
 
   protected function loadAccountForRegistrationOrLinking($account_key) {
     $request = $this->getRequest();
     $viewer = $request->getUser();
 
     $account = null;
     $provider = null;
     $response = null;
 
     if (!$account_key) {
       $response = $this->renderError(
         pht('Request did not include account key.'));
       return array($account, $provider, $response);
     }
 
     // NOTE: We're using the omnipotent user because the actual user may not
     // be logged in yet, and because we want to tailor an error message to
     // distinguish between "not usable" and "does not exist". We do explicit
     // checks later on to make sure this account is valid for the intended
     // operation. This requires edit permission for completeness and consistency
     // but it won't actually be meaningfully checked because we're using the
     // omnipotent user.
 
     $account = id(new PhabricatorExternalAccountQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withAccountSecrets(array($account_key))
       ->needImages(true)
       ->requireCapabilities(
         array(
           PhabricatorPolicyCapability::CAN_VIEW,
           PhabricatorPolicyCapability::CAN_EDIT,
         ))
       ->executeOne();
 
     if (!$account) {
       $response = $this->renderError(pht('No valid linkable account.'));
       return array($account, $provider, $response);
     }
 
     if ($account->getUserPHID()) {
       if ($account->getUserPHID() != $viewer->getPHID()) {
         $response = $this->renderError(
           pht(
             'The account you are attempting to register or link is already '.
             'linked to another user.'));
       } else {
         $response = $this->renderError(
           pht(
             'The account you are attempting to link is already linked '.
             'to your account.'));
       }
       return array($account, $provider, $response);
     }
 
     $registration_key = $request->getCookie(
       PhabricatorCookies::COOKIE_REGISTRATION);
 
     // NOTE: This registration key check is not strictly necessary, because
     // we're only creating new accounts, not linking existing accounts. It
     // might be more hassle than it is worth, especially for email.
     //
     // The attack this prevents is getting to the registration screen, then
     // copy/pasting the URL and getting someone else to click it and complete
     // the process. They end up with an account bound to credentials you
     // control. This doesn't really let you do anything meaningful, though,
     // since you could have simply completed the process yourself.
 
     if (!$registration_key) {
       $response = $this->renderError(
         pht(
           'Your browser did not submit a registration key with the request. '.
           'You must use the same browser to begin and complete registration. '.
           'Check that cookies are enabled and try again.'));
       return array($account, $provider, $response);
     }
 
     // We store the digest of the key rather than the key itself to prevent a
     // theoretical attacker with read-only access to the database from
     // hijacking registration sessions.
 
     $actual = $account->getProperty('registrationKey');
     $expect = PhabricatorHash::weakDigest($registration_key);
     if (!phutil_hashes_are_identical($actual, $expect)) {
       $response = $this->renderError(
         pht(
           'Your browser submitted a different registration key than the one '.
           'associated with this account. You may need to clear your cookies.'));
       return array($account, $provider, $response);
     }
 
     $config = $account->getProviderConfig();
     if (!$config->getIsEnabled()) {
       $response = $this->renderError(
         pht(
           'The account you are attempting to register with uses a disabled '.
           'authentication provider ("%s"). An administrator may have '.
           'recently disabled this provider.',
           $config->getDisplayName()));
       return array($account, $provider, $response);
     }
 
     $provider = $config->getProvider();
 
     return array($account, $provider, null);
   }
 
   protected function loadInvite() {
     $invite_cookie = PhabricatorCookies::COOKIE_INVITE;
     $invite_code = $this->getRequest()->getCookie($invite_cookie);
     if (!$invite_code) {
       return null;
     }
 
     $engine = id(new PhabricatorAuthInviteEngine())
       ->setViewer($this->getViewer())
       ->setUserHasConfirmedVerify(true);
 
     try {
       return $engine->processInviteCode($invite_code);
     } catch (Exception $ex) {
       // If this fails for any reason, just drop the invite. In normal
       // circumstances, we gave them a detailed explanation of any error
       // before they jumped into this workflow.
       return null;
     }
   }
 
   protected function renderInviteHeader(PhabricatorAuthInvite $invite) {
     $viewer = $this->getViewer();
 
     // Since the user hasn't registered yet, they may not be able to see other
     // user accounts. Load the inviting user with the omnipotent viewer.
     $omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
 
     $invite_author = id(new PhabricatorPeopleQuery())
       ->setViewer($omnipotent_viewer)
       ->withPHIDs(array($invite->getAuthorPHID()))
       ->needProfileImage(true)
       ->executeOne();
 
     // If we can't load the author for some reason, just drop this message.
     // We lose the value of contextualizing things without author details.
     if (!$invite_author) {
       return null;
     }
 
     $invite_item = id(new PHUIObjectItemView())
       ->setHeader(
         pht(
           'Welcome to %s!',
           PlatformSymbols::getPlatformServerName()))
       ->setImageURI($invite_author->getProfileImageURI())
       ->addAttribute(
         pht(
           '%s has invited you to join %s.',
           $invite_author->getFullName(),
           PlatformSymbols::getPlatformServerName()));
 
     $invite_list = id(new PHUIObjectItemListView())
       ->addItem($invite_item)
       ->setFlush(true);
 
     return id(new PHUIBoxView())
       ->addMargin(PHUI::MARGIN_LARGE)
       ->appendChild($invite_list);
   }
 
 
   final protected function newCustomStartMessage() {
     $viewer = $this->getViewer();
 
     $text = PhabricatorAuthMessage::loadMessageText(
       $viewer,
       PhabricatorAuthLoginMessageType::MESSAGEKEY);
 
     if (!phutil_nonempty_string($text)) {
       return null;
     }
 
     $remarkup_view = new PHUIRemarkupView($viewer, $text);
 
     return phutil_tag(
       'div',
       array(
         'class' => 'auth-custom-message',
       ),
       $remarkup_view);
   }
 
 }
diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
index 251c8284ef..30d85b6fac 100644
--- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
+++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
@@ -1,1171 +1,1180 @@
 <?php
 
 /**
  *
  * @task use      Using Sessions
  * @task new      Creating Sessions
  * @task hisec    High Security
  * @task partial  Partial Sessions
  * @task onetime  One Time Login URIs
  * @task cache    User Cache
  */
 final class PhabricatorAuthSessionEngine extends Phobject {
 
   /**
    * Session issued to normal users after they login through a standard channel.
    * Associates the client with a standard user identity.
    */
   const KIND_USER      = 'U';
 
 
   /**
    * Session issued to users who login with some sort of credentials but do not
    * have full accounts. These are sometimes called "grey users".
    *
    * TODO: We do not currently issue these sessions, see T4310.
    */
   const KIND_EXTERNAL  = 'X';
 
 
   /**
    * Session issued to logged-out users which has no real identity information.
    * Its purpose is to protect logged-out users from CSRF.
    */
   const KIND_ANONYMOUS = 'A';
 
 
   /**
    * Session kind isn't known.
    */
   const KIND_UNKNOWN   = '?';
 
 
   const ONETIME_RECOVER = 'recover';
   const ONETIME_RESET = 'reset';
   const ONETIME_WELCOME = 'welcome';
   const ONETIME_USERNAME = 'rename';
 
 
   private $workflowKey;
   private $request;
 
   public function setWorkflowKey($workflow_key) {
     $this->workflowKey = $workflow_key;
     return $this;
   }
 
   public function getWorkflowKey() {
 
     // TODO: A workflow key should become required in order to issue an MFA
     // challenge, but allow things to keep working for now until we can update
     // callsites.
     if ($this->workflowKey === null) {
       return 'legacy';
     }
 
     return $this->workflowKey;
   }
 
   public function getRequest() {
     return $this->request;
   }
 
 
   /**
    * Get the session kind (e.g., anonymous, user, external account) from a
    * session token. Returns a `KIND_` constant.
    *
-   * @param   string  Session token.
+   * @param   string  $session_token Session token.
    * @return  const   Session kind constant.
    */
   public static function getSessionKindFromToken($session_token) {
     if (strpos($session_token, '/') === false) {
       // Old-style session, these are all user sessions.
       return self::KIND_USER;
     }
 
     list($kind, $key) = explode('/', $session_token, 2);
 
     switch ($kind) {
       case self::KIND_ANONYMOUS:
       case self::KIND_USER:
       case self::KIND_EXTERNAL:
         return $kind;
       default:
         return self::KIND_UNKNOWN;
     }
   }
 
 
   /**
    * Load the user identity associated with a session of a given type,
    * identified by token.
    *
    * When the user presents a session token to an API, this method verifies
    * it is of the correct type and loads the corresponding identity if the
    * session exists and is valid.
    *
    * NOTE: `$session_type` is the type of session that is required by the
    * loading context. This prevents use of a Conduit sesssion as a Web
    * session, for example.
    *
-   * @param const The type of session to load.
-   * @param string The session token.
+   * @param const $session_type The type of session to load.
+   * @param string $session_token The session token.
    * @return PhabricatorUser|null
    * @task use
    */
   public function loadUserForSession($session_type, $session_token) {
     $session_kind = self::getSessionKindFromToken($session_token);
     switch ($session_kind) {
       case self::KIND_ANONYMOUS:
         // Don't bother trying to load a user for an anonymous session, since
         // neither the session nor the user exist.
         return null;
       case self::KIND_UNKNOWN:
         // If we don't know what kind of session this is, don't go looking for
         // it.
         return null;
       case self::KIND_USER:
         break;
       case self::KIND_EXTERNAL:
         // TODO: Implement these (T4310).
         return null;
     }
 
     $session_table = new PhabricatorAuthSession();
     $user_table = new PhabricatorUser();
     $conn = $session_table->establishConnection('r');
 
     // TODO: See T13225. We're moving sessions to a more modern digest
     // algorithm, but still accept older cookies for compatibility.
     $session_key = PhabricatorAuthSession::newSessionDigest(
       new PhutilOpaqueEnvelope($session_token));
     $weak_key = PhabricatorHash::weakDigest($session_token);
 
     $cache_parts = $this->getUserCacheQueryParts($conn);
     list($cache_selects, $cache_joins, $cache_map, $types_map) = $cache_parts;
 
     $info = queryfx_one(
       $conn,
       'SELECT
           s.id AS s_id,
           s.phid AS s_phid,
           s.sessionExpires AS s_sessionExpires,
           s.sessionStart AS s_sessionStart,
           s.highSecurityUntil AS s_highSecurityUntil,
           s.isPartial AS s_isPartial,
           s.signedLegalpadDocuments as s_signedLegalpadDocuments,
           IF(s.sessionKey = %P, 1, 0) as s_weak,
           u.*
           %Q
         FROM %R u JOIN %R s ON u.phid = s.userPHID
         AND s.type = %s AND s.sessionKey IN (%P, %P) %Q',
       new PhutilOpaqueEnvelope($weak_key),
       $cache_selects,
       $user_table,
       $session_table,
       $session_type,
       new PhutilOpaqueEnvelope($session_key),
       new PhutilOpaqueEnvelope($weak_key),
       $cache_joins);
 
     if (!$info) {
       return null;
     }
 
     // TODO: Remove this, see T13225.
     $is_weak = (bool)$info['s_weak'];
     unset($info['s_weak']);
 
     $session_dict = array(
       'userPHID' => $info['phid'],
       'sessionKey' => $session_key,
       'type' => $session_type,
     );
 
     $cache_raw = array_fill_keys($cache_map, null);
     foreach ($info as $key => $value) {
       if (strncmp($key, 's_', 2) === 0) {
         unset($info[$key]);
         $session_dict[substr($key, 2)] = $value;
         continue;
       }
 
       if (isset($cache_map[$key])) {
         unset($info[$key]);
         $cache_raw[$cache_map[$key]] = $value;
         continue;
       }
     }
 
     $user = $user_table->loadFromArray($info);
 
     $cache_raw = $this->filterRawCacheData($user, $types_map, $cache_raw);
     $user->attachRawCacheData($cache_raw);
 
     switch ($session_type) {
       case PhabricatorAuthSession::TYPE_WEB:
         // Explicitly prevent bots and mailing lists from establishing web
         // sessions. It's normally impossible to attach authentication to these
         // accounts, and likewise impossible to generate sessions, but it's
         // technically possible that a session could exist in the database. If
         // one does somehow, refuse to load it.
         if (!$user->canEstablishWebSessions()) {
           return null;
         }
         break;
     }
 
     $session = id(new PhabricatorAuthSession())->loadFromArray($session_dict);
 
     $this->extendSession($session);
 
     // TODO: Remove this, see T13225.
     if ($is_weak) {
       $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
         $conn_w = $session_table->establishConnection('w');
         queryfx(
           $conn_w,
           'UPDATE %T SET sessionKey = %P WHERE id = %d',
           $session->getTableName(),
           new PhutilOpaqueEnvelope($session_key),
           $session->getID());
       unset($unguarded);
     }
 
     $user->attachSession($session);
     return $user;
   }
 
 
   /**
    * Issue a new session key for a given identity. Phabricator supports
    * different types of sessions (like "web" and "conduit") and each session
    * type may have multiple concurrent sessions (this allows a user to be
    * logged in on multiple browsers at the same time, for instance).
    *
    * Note that this method is transport-agnostic and does not set cookies or
    * issue other types of tokens, it ONLY generates a new session key.
    *
    * You can configure the maximum number of concurrent sessions for various
    * session types in the Phabricator configuration.
    *
-   * @param   const     Session type constant (see
+   * @param   const     $session_type Session type constant (see
    *                    @{class:PhabricatorAuthSession}).
-   * @param   phid|null Identity to establish a session for, usually a user
-   *                    PHID. With `null`, generates an anonymous session.
-   * @param   bool      True to issue a partial session.
+   * @param   phid|null $identity_phid Identity to establish a session for,
+   *                    usually a user PHID. With `null`, generates an
+   *                    anonymous session.
+   * @param   bool      $partial True to issue a partial session.
    * @return  string    Newly generated session key.
    */
   public function establishSession($session_type, $identity_phid, $partial) {
     // Consume entropy to generate a new session key, forestalling the eventual
     // heat death of the universe.
     $session_key = Filesystem::readRandomCharacters(40);
 
     if ($identity_phid === null) {
       return self::KIND_ANONYMOUS.'/'.$session_key;
     }
 
     $session_table = new PhabricatorAuthSession();
     $conn_w = $session_table->establishConnection('w');
 
     // This has a side effect of validating the session type.
     $session_ttl = PhabricatorAuthSession::getSessionTypeTTL(
       $session_type,
       $partial);
 
     $digest_key = PhabricatorAuthSession::newSessionDigest(
       new PhutilOpaqueEnvelope($session_key));
 
     // Logging-in users don't have CSRF stuff yet, so we have to unguard this
     // write.
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       id(new PhabricatorAuthSession())
         ->setUserPHID($identity_phid)
         ->setType($session_type)
         ->setSessionKey($digest_key)
         ->setSessionStart(time())
         ->setSessionExpires(time() + $session_ttl)
         ->setIsPartial($partial ? 1 : 0)
         ->setSignedLegalpadDocuments(0)
         ->save();
 
       $log = PhabricatorUserLog::initializeNewLog(
         null,
         $identity_phid,
         ($partial
           ? PhabricatorPartialLoginUserLogType::LOGTYPE
           : PhabricatorLoginUserLogType::LOGTYPE));
 
       $log->setDetails(
         array(
           'session_type' => $session_type,
         ));
       $log->setSession($digest_key);
       $log->save();
     unset($unguarded);
 
     $info = id(new PhabricatorAuthSessionInfo())
       ->setSessionType($session_type)
       ->setIdentityPHID($identity_phid)
       ->setIsPartial($partial);
 
     $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
     foreach ($extensions as $extension) {
       $extension->didEstablishSession($info);
     }
 
     return $session_key;
   }
 
 
   /**
    * Terminate all of a user's login sessions.
    *
    * This is used when users change passwords, linked accounts, or add
    * multifactor authentication.
    *
-   * @param PhabricatorUser User whose sessions should be terminated.
-   * @param string|null Optionally, one session to keep. Normally, the current
-   *   login session.
+   * @param PhabricatorUser $user User whose sessions should be terminated.
+   * @param string|null? $except_session Optionally, one session to keep.
+   *   Normally, the current login session.
    *
    * @return void
    */
   public function terminateLoginSessions(
     PhabricatorUser $user,
     PhutilOpaqueEnvelope $except_session = null) {
 
     $sessions = id(new PhabricatorAuthSessionQuery())
       ->setViewer($user)
       ->withIdentityPHIDs(array($user->getPHID()))
       ->execute();
 
     if ($except_session !== null) {
       $except_session = PhabricatorAuthSession::newSessionDigest(
         $except_session);
     }
 
     foreach ($sessions as $key => $session) {
       if ($except_session !== null) {
         $is_except = phutil_hashes_are_identical(
           $session->getSessionKey(),
           $except_session);
         if ($is_except) {
           continue;
         }
       }
 
       $session->delete();
     }
   }
 
   public function logoutSession(
     PhabricatorUser $user,
     PhabricatorAuthSession $session) {
 
     $log = PhabricatorUserLog::initializeNewLog(
       $user,
       $user->getPHID(),
       PhabricatorLogoutUserLogType::LOGTYPE);
     $log->save();
 
     $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
     foreach ($extensions as $extension) {
       $extension->didLogout($user, array($session));
     }
 
     $session->delete();
   }
 
 
 /* -(  High Security  )------------------------------------------------------ */
 
 
   /**
    * Require the user respond to a high security (MFA) check.
    *
    * This method differs from @{method:requireHighSecuritySession} in that it
    * does not upgrade the user's session as a side effect. This method is
    * appropriate for one-time checks.
    *
-   * @param PhabricatorUser User whose session needs to be in high security.
-   * @param AphrontRequest  Current request.
-   * @param string          URI to return the user to if they cancel.
+   * @param PhabricatorUser $viewer User whose session needs to be in high
+   *   security.
+   * @param AphrontRequest  $request Current request.
+   * @param string          $cancel_uri URI to return the user to if they
+   *   cancel.
    * @return PhabricatorAuthHighSecurityToken Security token.
    * @task hisec
    */
   public function requireHighSecurityToken(
     PhabricatorUser $viewer,
     AphrontRequest $request,
     $cancel_uri) {
 
     return $this->newHighSecurityToken(
       $viewer,
       $request,
       $cancel_uri,
       false,
       false);
   }
 
 
   /**
    * Require high security, or prompt the user to enter high security.
    *
    * If the user's session is in high security, this method will return a
    * token. Otherwise, it will throw an exception which will eventually
    * be converted into a multi-factor authentication workflow.
    *
    * This method upgrades the user's session to high security for a short
    * period of time, and is appropriate if you anticipate they may need to
    * take multiple high security actions. To perform a one-time check instead,
    * use @{method:requireHighSecurityToken}.
    *
-   * @param PhabricatorUser User whose session needs to be in high security.
-   * @param AphrontRequest  Current request.
-   * @param string          URI to return the user to if they cancel.
-   * @param bool            True to jump partial sessions directly into high
-   *                        security instead of just upgrading them to full
-   *                        sessions.
+   * @param PhabricatorUser $viewer User whose session needs to be in high
+   *                        security.
+   * @param AphrontRequest  $request Current request.
+   * @param string          $cancel_uri URI to return the user to if they
+   *                        cancel.
+   * @param bool?           $jump_into_hisec True to jump partial sessions
+   *                        directly into high security instead of just
+   *                        upgrading them to full sessions.
    * @return PhabricatorAuthHighSecurityToken Security token.
    * @task hisec
    */
   public function requireHighSecuritySession(
     PhabricatorUser $viewer,
     AphrontRequest $request,
     $cancel_uri,
     $jump_into_hisec = false) {
 
     return $this->newHighSecurityToken(
       $viewer,
       $request,
       $cancel_uri,
       $jump_into_hisec,
       true);
   }
 
   private function newHighSecurityToken(
     PhabricatorUser $viewer,
     AphrontRequest $request,
     $cancel_uri,
     $jump_into_hisec,
     $upgrade_session) {
 
     if (!$viewer->hasSession()) {
       throw new Exception(
         pht('Requiring a high-security session from a user with no session!'));
     }
 
     // TODO: If a user answers a "requireHighSecurityToken()" prompt and hits
     // a "requireHighSecuritySession()" prompt a short time later, the one-shot
     // token should be good enough to upgrade the session.
 
     $session = $viewer->getSession();
 
     // Check if the session is already in high security mode.
     $token = $this->issueHighSecurityToken($session);
     if ($token) {
       return $token;
     }
 
     // Load the multi-factor auth sources attached to this account. Note that
     // we order factors from oldest to newest, which is not the default query
     // ordering but makes the greatest sense in context.
     $factors = id(new PhabricatorAuthFactorConfigQuery())
       ->setViewer($viewer)
       ->withUserPHIDs(array($viewer->getPHID()))
       ->withFactorProviderStatuses(
         array(
           PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
           PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
         ))
       ->execute();
 
     // Sort factors in the same order that they appear in on the Settings
     // panel. This means that administrators changing provider statuses may
     // change the order of prompts for users, but the alternative is that the
     // Settings panel order disagrees with the prompt order, which seems more
     // disruptive.
     $factors = msortv($factors, 'newSortVector');
 
     // If the account has no associated multi-factor auth, just issue a token
     // without putting the session into high security mode. This is generally
     // easier for users. A minor but desirable side effect is that when a user
     // adds an auth factor, existing sessions won't get a free pass into hisec,
     // since they never actually got marked as hisec.
     if (!$factors) {
       return $this->issueHighSecurityToken($session, true)
         ->setIsUnchallengedToken(true);
     }
 
     $this->request = $request;
     foreach ($factors as $factor) {
       $factor->setSessionEngine($this);
     }
 
     // Check for a rate limit without awarding points, so the user doesn't
     // get partway through the workflow only to get blocked.
     PhabricatorSystemActionEngine::willTakeAction(
       array($viewer->getPHID()),
       new PhabricatorAuthTryFactorAction(),
       0);
 
     $now = PhabricatorTime::getNow();
 
     // We need to do challenge validation first, since this happens whether you
     // submitted responses or not. You can't get a "bad response" error before
     // you actually submit a response, but you can get a "wait, we can't
     // issue a challenge yet" response. Load all issued challenges which are
     // currently valid.
     $challenges = id(new PhabricatorAuthChallengeQuery())
       ->setViewer($viewer)
       ->withFactorPHIDs(mpull($factors, 'getPHID'))
       ->withUserPHIDs(array($viewer->getPHID()))
       ->withChallengeTTLBetween($now, null)
       ->execute();
 
     PhabricatorAuthChallenge::newChallengeResponsesFromRequest(
       $challenges,
       $request);
 
     $challenge_map = mgroup($challenges, 'getFactorPHID');
 
     $validation_results = array();
     $ok = true;
 
     // Validate each factor against issued challenges. For example, this
     // prevents you from receiving or responding to a TOTP challenge if another
     // challenge was recently issued to a different session.
     foreach ($factors as $factor) {
       $factor_phid = $factor->getPHID();
       $issued_challenges = idx($challenge_map, $factor_phid, array());
       $provider = $factor->getFactorProvider();
       $impl = $provider->getFactor();
 
       $new_challenges = $impl->getNewIssuedChallenges(
         $factor,
         $viewer,
         $issued_challenges);
 
       // NOTE: We may get a list of challenges back, or may just get an early
       // result. For example, this can happen on an SMS factor if all SMS
       // mailers have been disabled.
       if ($new_challenges instanceof PhabricatorAuthFactorResult) {
         $result = $new_challenges;
 
         if (!$result->getIsValid()) {
           $ok = false;
         }
 
         $validation_results[$factor_phid] = $result;
         $challenge_map[$factor_phid] = $issued_challenges;
         continue;
       }
 
       foreach ($new_challenges as $new_challenge) {
         $issued_challenges[] = $new_challenge;
       }
       $challenge_map[$factor_phid] = $issued_challenges;
 
       if (!$issued_challenges) {
         continue;
       }
 
       $result = $impl->getResultFromIssuedChallenges(
         $factor,
         $viewer,
         $issued_challenges);
 
       if (!$result) {
         continue;
       }
 
       if (!$result->getIsValid()) {
         $ok = false;
       }
 
       $validation_results[$factor_phid] = $result;
     }
 
     if ($request->isHTTPPost()) {
       $request->validateCSRF();
       if ($request->getExists(AphrontRequest::TYPE_HISEC)) {
 
         // Limit factor verification rates to prevent brute force attacks.
         $any_attempt = false;
         foreach ($factors as $factor) {
           $factor_phid = $factor->getPHID();
 
           $provider = $factor->getFactorProvider();
           $impl = $provider->getFactor();
 
           // If we already have a result (normally "wait..."), we won't try
           // to validate whatever the user submitted, so this doesn't count as
           // an attempt for rate limiting purposes.
           if (isset($validation_results[$factor_phid])) {
             continue;
           }
 
           if ($impl->getRequestHasChallengeResponse($factor, $request)) {
             $any_attempt = true;
             break;
           }
         }
 
         if ($any_attempt) {
           PhabricatorSystemActionEngine::willTakeAction(
             array($viewer->getPHID()),
             new PhabricatorAuthTryFactorAction(),
             1);
         }
 
         foreach ($factors as $factor) {
           $factor_phid = $factor->getPHID();
 
           // If we already have a validation result from previously issued
           // challenges, skip validating this factor.
           if (isset($validation_results[$factor_phid])) {
             continue;
           }
 
           $issued_challenges = idx($challenge_map, $factor_phid, array());
 
           $provider = $factor->getFactorProvider();
           $impl = $provider->getFactor();
 
           $validation_result = $impl->getResultFromChallengeResponse(
             $factor,
             $viewer,
             $request,
             $issued_challenges);
 
           if (!$validation_result->getIsValid()) {
             $ok = false;
           }
 
           $validation_results[$factor_phid] = $validation_result;
         }
 
         if ($ok) {
           // We're letting you through, so mark all the challenges you
           // responded to as completed. These challenges can never be used
           // again, even by the same session and workflow: you can't use the
           // same response to take two different actions, even if those actions
           // are of the same type.
           foreach ($validation_results as $validation_result) {
             $challenge = $validation_result->getAnsweredChallenge()
               ->markChallengeAsCompleted();
           }
 
           // Give the user a credit back for a successful factor verification.
           if ($any_attempt) {
             PhabricatorSystemActionEngine::willTakeAction(
               array($viewer->getPHID()),
               new PhabricatorAuthTryFactorAction(),
               -1);
           }
 
           if ($session->getIsPartial() && !$jump_into_hisec) {
             // If we have a partial session and are not jumping directly into
             // hisec, just issue a token without putting it in high security
             // mode.
             return $this->issueHighSecurityToken($session, true);
           }
 
           // If we aren't upgrading the session itself, just issue a token.
           if (!$upgrade_session) {
             return $this->issueHighSecurityToken($session, true);
           }
 
           $until = time() + phutil_units('15 minutes in seconds');
           $session->setHighSecurityUntil($until);
 
           queryfx(
             $session->establishConnection('w'),
             'UPDATE %T SET highSecurityUntil = %d WHERE id = %d',
             $session->getTableName(),
             $until,
             $session->getID());
 
           $log = PhabricatorUserLog::initializeNewLog(
             $viewer,
             $viewer->getPHID(),
             PhabricatorEnterHisecUserLogType::LOGTYPE);
           $log->save();
         } else {
           $log = PhabricatorUserLog::initializeNewLog(
             $viewer,
             $viewer->getPHID(),
             PhabricatorFailHisecUserLogType::LOGTYPE);
           $log->save();
         }
       }
     }
 
     $token = $this->issueHighSecurityToken($session);
     if ($token) {
       return $token;
     }
 
     // If we don't have a validation result for some factors yet, fill them
     // in with an empty result so form rendering doesn't have to care if the
     // results exist or not. This happens when you first load the form and have
     // not submitted any responses yet.
     foreach ($factors as $factor) {
       $factor_phid = $factor->getPHID();
       if (isset($validation_results[$factor_phid])) {
         continue;
       }
 
       $issued_challenges = idx($challenge_map, $factor_phid, array());
 
       $validation_results[$factor_phid] = $impl->getResultForPrompt(
         $factor,
         $viewer,
         $request,
         $issued_challenges);
     }
 
     throw id(new PhabricatorAuthHighSecurityRequiredException())
       ->setCancelURI($cancel_uri)
       ->setIsSessionUpgrade($upgrade_session)
       ->setFactors($factors)
       ->setFactorValidationResults($validation_results);
   }
 
 
   /**
    * Issue a high security token for a session, if authorized.
    *
-   * @param PhabricatorAuthSession Session to issue a token for.
-   * @param bool Force token issue.
+   * @param PhabricatorAuthSession $session Session to issue a token for.
+   * @param bool? $force Force token issue.
    * @return PhabricatorAuthHighSecurityToken|null Token, if authorized.
    * @task hisec
    */
   private function issueHighSecurityToken(
     PhabricatorAuthSession $session,
     $force = false) {
 
     if ($session->isHighSecuritySession() || $force) {
       return new PhabricatorAuthHighSecurityToken();
     }
 
     return null;
   }
 
 
   /**
    * Render a form for providing relevant multi-factor credentials.
    *
-   * @param PhabricatorUser Viewing user.
-   * @param AphrontRequest Current request.
+   * @param array $factors
+   * @param array $validation_results
+   * @param PhabricatorUser $viewer Viewing user.
+   * @param AphrontRequest $request Current request.
    * @return AphrontFormView Renderable form.
    * @task hisec
    */
   public function renderHighSecurityForm(
     array $factors,
     array $validation_results,
     PhabricatorUser $viewer,
     AphrontRequest $request) {
     assert_instances_of($validation_results, 'PhabricatorAuthFactorResult');
 
     $form = id(new AphrontFormView())
       ->setUser($viewer)
       ->appendRemarkupInstructions('');
 
     $answered = array();
     foreach ($factors as $factor) {
       $result = $validation_results[$factor->getPHID()];
 
       $provider = $factor->getFactorProvider();
       $impl = $provider->getFactor();
 
       $impl->renderValidateFactorForm(
         $factor,
         $form,
         $viewer,
         $result);
 
       $answered_challenge = $result->getAnsweredChallenge();
       if ($answered_challenge) {
         $answered[] = $answered_challenge;
       }
     }
 
     $form->appendRemarkupInstructions('');
 
     if ($answered) {
       $http_params = PhabricatorAuthChallenge::newHTTPParametersFromChallenges(
         $answered);
       foreach ($http_params as $key => $value) {
         $form->addHiddenInput($key, $value);
       }
     }
 
     return $form;
   }
 
 
   /**
    * Strip the high security flag from a session.
    *
    * Kicks a session out of high security and logs the exit.
    *
-   * @param PhabricatorUser Acting user.
-   * @param PhabricatorAuthSession Session to return to normal security.
+   * @param PhabricatorUser $viewer Acting user.
+   * @param PhabricatorAuthSession $session Session to return to normal
+   *  security.
    * @return void
    * @task hisec
    */
   public function exitHighSecurity(
     PhabricatorUser $viewer,
     PhabricatorAuthSession $session) {
 
     if (!$session->getHighSecurityUntil()) {
       return;
     }
 
     queryfx(
       $session->establishConnection('w'),
       'UPDATE %T SET highSecurityUntil = NULL WHERE id = %d',
       $session->getTableName(),
       $session->getID());
 
     $log = PhabricatorUserLog::initializeNewLog(
       $viewer,
       $viewer->getPHID(),
       PhabricatorExitHisecUserLogType::LOGTYPE);
     $log->save();
   }
 
 
 /* -(  Partial Sessions  )--------------------------------------------------- */
 
 
   /**
    * Upgrade a partial session to a full session.
    *
-   * @param PhabricatorAuthSession Session to upgrade.
+   * @param PhabricatorUser $viewer Viewer whose session should upgrade.
    * @return void
    * @task partial
    */
   public function upgradePartialSession(PhabricatorUser $viewer) {
 
     if (!$viewer->hasSession()) {
       throw new Exception(
         pht('Upgrading partial session of user with no session!'));
     }
 
     $session = $viewer->getSession();
 
     if (!$session->getIsPartial()) {
       throw new Exception(pht('Session is not partial!'));
     }
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       $session->setIsPartial(0);
 
       queryfx(
         $session->establishConnection('w'),
         'UPDATE %T SET isPartial = %d WHERE id = %d',
         $session->getTableName(),
         0,
         $session->getID());
 
       $log = PhabricatorUserLog::initializeNewLog(
         $viewer,
         $viewer->getPHID(),
         PhabricatorFullLoginUserLogType::LOGTYPE);
       $log->save();
     unset($unguarded);
   }
 
 
 /* -(  Legalpad Documents )-------------------------------------------------- */
 
 
   /**
    * Upgrade a session to have all legalpad documents signed.
    *
-   * @param PhabricatorUser User whose session should upgrade.
-   * @param array LegalpadDocument objects
+   * @param PhabricatorUser $viewer User whose session should upgrade.
+   * @param array $docs LegalpadDocument objects
    * @return void
    * @task partial
    */
   public function signLegalpadDocuments(PhabricatorUser $viewer, array $docs) {
 
     if (!$viewer->hasSession()) {
       throw new Exception(
         pht('Signing session legalpad documents of user with no session!'));
     }
 
     $session = $viewer->getSession();
 
     if ($session->getSignedLegalpadDocuments()) {
       throw new Exception(pht(
         'Session has already signed required legalpad documents!'));
     }
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       $session->setSignedLegalpadDocuments(1);
 
       queryfx(
         $session->establishConnection('w'),
         'UPDATE %T SET signedLegalpadDocuments = %d WHERE id = %d',
         $session->getTableName(),
         1,
         $session->getID());
 
       if (!empty($docs)) {
         $log = PhabricatorUserLog::initializeNewLog(
           $viewer,
           $viewer->getPHID(),
           PhabricatorSignDocumentsUserLogType::LOGTYPE);
         $log->save();
       }
     unset($unguarded);
   }
 
 
 /* -(  One Time Login URIs  )------------------------------------------------ */
 
 
   /**
    * Retrieve a temporary, one-time URI which can log in to an account.
    *
    * These URIs are used for password recovery and to regain access to accounts
    * which users have been locked out of.
    *
-   * @param PhabricatorUser User to generate a URI for.
-   * @param PhabricatorUserEmail Optionally, email to verify when
+   * @param PhabricatorUser $user User to generate a URI for.
+   * @param PhabricatorUserEmail? $email Optionally, email to verify when
    *  link is used.
-   * @param string Optional context string for the URI. This is purely cosmetic
-   *  and used only to customize workflow and error messages.
-   * @param bool True to generate a URI which forces an immediate upgrade to
-   *  a full session, bypassing MFA and other login checks.
+   * @param string? $type Optional context string for the URI. This is purely
+   *  cosmetic and used only to customize workflow and error messages.
+   * @param bool? $force_full_session True to generate a URI which forces an
+   *  immediate upgrade to a full session, bypassing MFA and other login
+   *  checks.
    * @return string Login URI.
    * @task onetime
    */
   public function getOneTimeLoginURI(
     PhabricatorUser $user,
     PhabricatorUserEmail $email = null,
     $type = self::ONETIME_RESET,
     $force_full_session = false) {
 
     $key = Filesystem::readRandomCharacters(32);
     $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
     $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       $token = id(new PhabricatorAuthTemporaryToken())
         ->setTokenResource($user->getPHID())
         ->setTokenType($onetime_type)
         ->setTokenExpires(time() + phutil_units('1 day in seconds'))
         ->setTokenCode($key_hash)
         ->setShouldForceFullSession($force_full_session)
         ->save();
     unset($unguarded);
 
     $uri = '/login/once/'.$type.'/'.$user->getID().'/'.$key.'/';
     if ($email) {
       $uri = $uri.$email->getID().'/';
     }
 
     try {
       $uri = PhabricatorEnv::getProductionURI($uri);
     } catch (Exception $ex) {
       // If a user runs `bin/auth recover` before configuring the base URI,
       // just show the path. We don't have any way to figure out the domain.
       // See T4132.
     }
 
     return $uri;
   }
 
 
   /**
    * Load the temporary token associated with a given one-time login key.
    *
-   * @param PhabricatorUser User to load the token for.
-   * @param PhabricatorUserEmail Optionally, email to verify when
+   * @param PhabricatorUser $user User to load the token for.
+   * @param PhabricatorUserEmail? $email Optionally, email to verify when
    *  link is used.
-   * @param string Key user is presenting as a valid one-time login key.
+   * @param string? $key Key user is presenting as a valid one-time login key.
    * @return PhabricatorAuthTemporaryToken|null Token, if one exists.
    * @task onetime
    */
   public function loadOneTimeLoginKey(
     PhabricatorUser $user,
     PhabricatorUserEmail $email = null,
     $key = null) {
 
     $key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
     $onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
 
     return id(new PhabricatorAuthTemporaryTokenQuery())
       ->setViewer($user)
       ->withTokenResources(array($user->getPHID()))
       ->withTokenTypes(array($onetime_type))
       ->withTokenCodes(array($key_hash))
       ->withExpired(false)
       ->executeOne();
   }
 
 
   /**
    * Hash a one-time login key for storage as a temporary token.
    *
-   * @param PhabricatorUser User this key is for.
-   * @param PhabricatorUserEmail Optionally, email to verify when
+   * @param PhabricatorUser $user User this key is for.
+   * @param PhabricatorUserEmail? $email Optionally, email to verify when
    *  link is used.
-   * @param string The one time login key.
+   * @param string? $key The one time login key.
    * @return string Hash of the key.
    * task onetime
    */
   private function getOneTimeLoginKeyHash(
     PhabricatorUser $user,
     PhabricatorUserEmail $email = null,
     $key = null) {
 
     $parts = array(
       $key,
       $user->getAccountSecret(),
     );
 
     if ($email) {
       $parts[] = $email->getVerificationCode();
     }
 
     return PhabricatorHash::weakDigest(implode(':', $parts));
   }
 
 
 /* -(  User Cache  )--------------------------------------------------------- */
 
 
   /**
    * @task cache
    */
   private function getUserCacheQueryParts(AphrontDatabaseConnection $conn) {
     $cache_selects = array();
     $cache_joins = array();
     $cache_map = array();
 
     $keys = array();
     $types_map = array();
 
     $cache_types = PhabricatorUserCacheType::getAllCacheTypes();
     foreach ($cache_types as $cache_type) {
       foreach ($cache_type->getAutoloadKeys() as $autoload_key) {
         $keys[] = $autoload_key;
         $types_map[$autoload_key] = $cache_type;
       }
     }
 
     $cache_table = id(new PhabricatorUserCache())->getTableName();
 
     $cache_idx = 1;
     foreach ($keys as $key) {
       $join_as = 'ucache_'.$cache_idx;
       $select_as = 'ucache_'.$cache_idx.'_v';
 
       $cache_selects[] = qsprintf(
         $conn,
         '%T.cacheData %T',
         $join_as,
         $select_as);
 
       $cache_joins[] = qsprintf(
         $conn,
         'LEFT JOIN %T AS %T ON u.phid = %T.userPHID
           AND %T.cacheIndex = %s',
         $cache_table,
         $join_as,
         $join_as,
         $join_as,
         PhabricatorHash::digestForIndex($key));
 
       $cache_map[$select_as] = $key;
 
       $cache_idx++;
     }
 
     if ($cache_selects) {
       $cache_selects = qsprintf($conn, ', %LQ', $cache_selects);
     } else {
       $cache_selects = qsprintf($conn, '');
     }
 
     if ($cache_joins) {
       $cache_joins = qsprintf($conn, '%LJ', $cache_joins);
     } else {
       $cache_joins = qsprintf($conn, '');
     }
 
     return array($cache_selects, $cache_joins, $cache_map, $types_map);
   }
 
   private function filterRawCacheData(
     PhabricatorUser $user,
     array $types_map,
     array $cache_raw) {
 
     foreach ($cache_raw as $cache_key => $cache_data) {
       $type = $types_map[$cache_key];
       if ($type->shouldValidateRawCacheData()) {
         if (!$type->isRawCacheDataValid($user, $cache_key, $cache_data)) {
           unset($cache_raw[$cache_key]);
         }
       }
     }
 
     return $cache_raw;
   }
 
   public function willServeRequestForUser(PhabricatorUser $user) {
     // We allow the login user to generate any missing cache data inline.
     $user->setAllowInlineCacheGeneration(true);
 
     // Switch to the user's translation.
     PhabricatorEnv::setLocaleCode($user->getTranslation());
 
     $extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
     foreach ($extensions as $extension) {
       $extension->willServeRequestForUser($user);
     }
   }
 
   private function extendSession(PhabricatorAuthSession $session) {
     $is_partial = $session->getIsPartial();
 
     // Don't extend partial sessions. You have a relatively short window to
     // upgrade into a full session, and your session expires otherwise.
     if ($is_partial) {
       return;
     }
 
     $session_type = $session->getType();
 
     $ttl = PhabricatorAuthSession::getSessionTypeTTL(
       $session_type,
       $session->getIsPartial());
 
     // If more than 20% of the time on this session has been used, refresh the
     // TTL back up to the full duration. The idea here is that sessions are
     // good forever if used regularly, but get GC'd when they fall out of use.
 
     $now = PhabricatorTime::getNow();
     if ($now + (0.80 * $ttl) <= $session->getSessionExpires()) {
       return;
     }
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       queryfx(
         $session->establishConnection('w'),
         'UPDATE %R SET sessionExpires = UNIX_TIMESTAMP() + %d
           WHERE id = %d',
         $session,
         $ttl,
         $session->getID());
     unset($unguarded);
   }
 
 
 }
diff --git a/src/applications/auth/password/PhabricatorAuthPasswordHashInterface.php b/src/applications/auth/password/PhabricatorAuthPasswordHashInterface.php
index af20db3ed4..f5317bef6f 100644
--- a/src/applications/auth/password/PhabricatorAuthPasswordHashInterface.php
+++ b/src/applications/auth/password/PhabricatorAuthPasswordHashInterface.php
@@ -1,27 +1,27 @@
 <?php
 
 interface PhabricatorAuthPasswordHashInterface {
 
   public function newPasswordDigest(
     PhutilOpaqueEnvelope $envelope,
     PhabricatorAuthPassword $password);
 
   /**
    * Return a list of strings which passwords associated with this object may
    * not be similar to.
    *
    * This method allows you to prevent users from selecting their username
    * as their password or picking other passwords which are trivially similar
    * to an account or object identifier.
    *
-   * @param PhabricatorUser The user selecting the password.
-   * @param PhabricatorAuthPasswordEngine The password engine updating a
+   * @param PhabricatorUser $viewer The user selecting the password.
+   * @param PhabricatorAuthPasswordEngine $engine The password engine updating a
    *  password.
    * @return list<string> Blocklist of nonsecret identifiers which the password
    *  should not be similar to.
    */
   public function newPasswordBlocklist(
     PhabricatorUser $viewer,
     PhabricatorAuthPasswordEngine $engine);
 
 }
diff --git a/src/applications/auth/provider/PhabricatorAuthProvider.php b/src/applications/auth/provider/PhabricatorAuthProvider.php
index 735dd42b4b..4b90daa2aa 100644
--- a/src/applications/auth/provider/PhabricatorAuthProvider.php
+++ b/src/applications/auth/provider/PhabricatorAuthProvider.php
@@ -1,594 +1,594 @@
 <?php
 
 abstract class PhabricatorAuthProvider extends Phobject {
 
   private $providerConfig;
 
   public function attachProviderConfig(PhabricatorAuthProviderConfig $config) {
     $this->providerConfig = $config;
     return $this;
   }
 
   public function hasProviderConfig() {
     return (bool)$this->providerConfig;
   }
 
   public function getProviderConfig() {
     if ($this->providerConfig === null) {
       throw new PhutilInvalidStateException('attachProviderConfig');
     }
     return $this->providerConfig;
   }
 
   public function getProviderConfigPHID() {
     return $this->getProviderConfig()->getPHID();
   }
 
   public function getConfigurationHelp() {
     return null;
   }
 
   public function getDefaultProviderConfig() {
     return id(new PhabricatorAuthProviderConfig())
       ->setProviderClass(get_class($this))
       ->setIsEnabled(1)
       ->setShouldAllowLogin(1)
       ->setShouldAllowRegistration(1)
       ->setShouldAllowLink(1)
       ->setShouldAllowUnlink(1);
   }
 
   public function getNameForCreate() {
     return $this->getProviderName();
   }
 
   public function getDescriptionForCreate() {
     return null;
   }
 
   public function getProviderKey() {
     return $this->getAdapter()->getAdapterKey();
   }
 
   public function getProviderType() {
     return $this->getAdapter()->getAdapterType();
   }
 
   public function getProviderDomain() {
     return $this->getAdapter()->getAdapterDomain();
   }
 
   public static function getAllBaseProviders() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->execute();
   }
 
   public static function getAllProviders() {
     static $providers;
 
     if ($providers === null) {
       $objects = self::getAllBaseProviders();
 
       $configs = id(new PhabricatorAuthProviderConfigQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->execute();
 
       $providers = array();
       foreach ($configs as $config) {
         if (!isset($objects[$config->getProviderClass()])) {
           // This configuration is for a provider which is not installed.
           continue;
         }
 
         $object = clone $objects[$config->getProviderClass()];
         $object->attachProviderConfig($config);
 
         $key = $object->getProviderKey();
         if (isset($providers[$key])) {
           throw new Exception(
             pht(
               "Two authentication providers use the same provider key ".
               "('%s'). Each provider must be identified by a unique key.",
               $key));
         }
         $providers[$key] = $object;
       }
     }
 
     return $providers;
   }
 
   public static function getAllEnabledProviders() {
     $providers = self::getAllProviders();
     foreach ($providers as $key => $provider) {
       if (!$provider->isEnabled()) {
         unset($providers[$key]);
       }
     }
     return $providers;
   }
 
   public static function getEnabledProviderByKey($provider_key) {
     return idx(self::getAllEnabledProviders(), $provider_key);
   }
 
   abstract public function getProviderName();
   abstract public function getAdapter();
 
   public function isEnabled() {
     return $this->getProviderConfig()->getIsEnabled();
   }
 
   public function shouldAllowLogin() {
     return $this->getProviderConfig()->getShouldAllowLogin();
   }
 
   public function shouldAllowRegistration() {
     if (!$this->shouldAllowLogin()) {
       return false;
     }
 
     return $this->getProviderConfig()->getShouldAllowRegistration();
   }
 
   public function shouldAllowAccountLink() {
     return $this->getProviderConfig()->getShouldAllowLink();
   }
 
   public function shouldAllowAccountUnlink() {
     return $this->getProviderConfig()->getShouldAllowUnlink();
   }
 
   public function shouldTrustEmails() {
     return $this->shouldAllowEmailTrustConfiguration() &&
            $this->getProviderConfig()->getShouldTrustEmails();
   }
 
   /**
    * Should we allow the adapter to be marked as "trusted". This is true for
    * all adapters except those that allow the user to type in emails (see
    * @{class:PhabricatorPasswordAuthProvider}).
    */
   public function shouldAllowEmailTrustConfiguration() {
     return true;
   }
 
   public function buildLoginForm(PhabricatorAuthStartController $controller) {
     return $this->renderLoginForm($controller->getRequest(), $mode = 'start');
   }
 
   public function buildInviteForm(PhabricatorAuthStartController $controller) {
     return $this->renderLoginForm($controller->getRequest(), $mode = 'invite');
   }
 
   abstract public function processLoginRequest(
     PhabricatorAuthLoginController $controller);
 
   public function buildLinkForm($controller) {
     return $this->renderLoginForm($controller->getRequest(), $mode = 'link');
   }
 
   public function shouldAllowAccountRefresh() {
     return true;
   }
 
   public function buildRefreshForm(
     PhabricatorAuthLinkController $controller) {
     return $this->renderLoginForm($controller->getRequest(), $mode = 'refresh');
   }
 
   protected function renderLoginForm(AphrontRequest $request, $mode) {
     throw new PhutilMethodNotImplementedException();
   }
 
   public function createProviders() {
     return array($this);
   }
 
   protected function willSaveAccount(PhabricatorExternalAccount $account) {
     return;
   }
 
   final protected function newExternalAccountForIdentifiers(
     array $identifiers) {
 
     assert_instances_of($identifiers, 'PhabricatorExternalAccountIdentifier');
 
     if (!$identifiers) {
       throw new Exception(
         pht(
           'Authentication provider (of class "%s") is attempting to '.
           'load or create an external account, but provided no account '.
           'identifiers.',
           get_class($this)));
     }
 
     $config = $this->getProviderConfig();
     $viewer = PhabricatorUser::getOmnipotentUser();
 
     $raw_identifiers = mpull($identifiers, 'getIdentifierRaw');
 
     $accounts = id(new PhabricatorExternalAccountQuery())
       ->setViewer($viewer)
       ->withProviderConfigPHIDs(array($config->getPHID()))
       ->withRawAccountIdentifiers($raw_identifiers)
       ->needAccountIdentifiers(true)
       ->execute();
     if (!$accounts) {
       $account = $this->newExternalAccount();
     } else if (count($accounts) === 1) {
       $account = head($accounts);
     } else {
       throw new Exception(
         pht(
           'Authentication provider (of class "%s") is attempting to load '.
           'or create an external account, but provided a list of '.
           'account identifiers which map to more than one account: %s.',
           get_class($this),
           implode(', ', $raw_identifiers)));
     }
 
     // See T13493. Add all the identifiers to the account. In the case where
     // an account initially has a lower-quality identifier (like an email
     // address) and later adds a higher-quality identifier (like a GUID), this
     // allows us to automatically upgrade toward the higher-quality identifier
     // and survive API changes which remove the lower-quality identifier more
     // gracefully.
 
     foreach ($identifiers as $identifier) {
       $account->appendIdentifier($identifier);
     }
 
     return $this->didUpdateAccount($account);
   }
 
   final protected function newExternalAccountForUser(PhabricatorUser $user) {
     $config = $this->getProviderConfig();
 
     // When a user logs in with a provider like username/password, they
     // always already have a Phabricator account (since there's no way they
     // could have a username otherwise).
 
     // These users should never go to registration, so we're building a
     // dummy "external account" which just links directly back to their
     // internal account.
 
     $account = id(new PhabricatorExternalAccountQuery())
       ->setViewer($user)
       ->withProviderConfigPHIDs(array($config->getPHID()))
       ->withUserPHIDs(array($user->getPHID()))
       ->executeOne();
     if (!$account) {
       $account = $this->newExternalAccount()
         ->setUserPHID($user->getPHID());
     }
 
     return $this->didUpdateAccount($account);
   }
 
   private function didUpdateAccount(PhabricatorExternalAccount $account) {
     $adapter = $this->getAdapter();
 
     $account->setUsername($adapter->getAccountName());
     $account->setRealName($adapter->getAccountRealName());
     $account->setEmail($adapter->getAccountEmail());
     $account->setAccountURI($adapter->getAccountURI());
 
     $account->setProfileImagePHID(null);
     $image_uri = $adapter->getAccountImageURI();
     if ($image_uri) {
       try {
         $name = PhabricatorSlug::normalize($this->getProviderName());
         $name = $name.'-profile.jpg';
 
         // TODO: If the image has not changed, we do not need to make a new
         // file entry for it, but there's no convenient way to do this with
         // PhabricatorFile right now. The storage will get shared, so the impact
         // here is negligible.
 
         $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
           $image_file = PhabricatorFile::newFromFileDownload(
             $image_uri,
             array(
               'name' => $name,
               'viewPolicy' => PhabricatorPolicies::POLICY_NOONE,
             ));
           if ($image_file->isViewableImage()) {
             $image_file
               ->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy())
               ->setCanCDN(true)
               ->save();
             $account->setProfileImagePHID($image_file->getPHID());
           } else {
             $image_file->delete();
           }
         unset($unguarded);
 
       } catch (Exception $ex) {
         // Log this but proceed, it's not especially important that we
         // be able to pull profile images.
         phlog($ex);
       }
     }
 
     $this->willSaveAccount($account);
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
     $account->save();
     unset($unguarded);
 
     return $account;
   }
 
   public function getLoginURI() {
     $app = PhabricatorApplication::getByClass('PhabricatorAuthApplication');
     return $app->getApplicationURI('/login/'.$this->getProviderKey().'/');
   }
 
   public function getSettingsURI() {
     return '/settings/panel/external/';
   }
 
   public function getStartURI() {
     $app = PhabricatorApplication::getByClass('PhabricatorAuthApplication');
     $uri = $app->getApplicationURI('/start/');
     return $uri;
   }
 
   public function isDefaultRegistrationProvider() {
     return false;
   }
 
   public function shouldRequireRegistrationPassword() {
     return false;
   }
 
   public function newDefaultExternalAccount() {
     return $this->newExternalAccount();
   }
 
   protected function newExternalAccount() {
     $config = $this->getProviderConfig();
     $adapter = $this->getAdapter();
 
     $account = id(new PhabricatorExternalAccount())
       ->setProviderConfigPHID($config->getPHID())
       ->attachAccountIdentifiers(array());
 
     // TODO: Remove this when these columns are removed. They no longer have
     // readers or writers (other than this callsite).
 
     $account
       ->setAccountType($adapter->getAdapterType())
       ->setAccountDomain($adapter->getAdapterDomain());
 
     // TODO: Remove this when "accountID" is removed; the column is not
     // nullable.
 
     $account->setAccountID('');
 
     return $account;
   }
 
   public function getLoginOrder() {
     return '500-'.$this->getProviderName();
   }
 
   protected function getLoginIcon() {
     return 'Generic';
   }
 
   public function newIconView() {
     return id(new PHUIIconView())
       ->setSpriteSheet(PHUIIconView::SPRITE_LOGIN)
       ->setSpriteIcon($this->getLoginIcon());
   }
 
   public function isLoginFormAButton() {
     return false;
   }
 
   public function renderConfigPropertyTransactionTitle(
     PhabricatorAuthProviderConfigTransaction $xaction) {
 
     return null;
   }
 
   public function readFormValuesFromProvider() {
     return array();
   }
 
   public function readFormValuesFromRequest(AphrontRequest $request) {
     return array();
   }
 
   public function processEditForm(
     AphrontRequest $request,
     array $values) {
 
     $errors = array();
     $issues = array();
 
     return array($errors, $issues, $values);
   }
 
   public function extendEditForm(
     AphrontRequest $request,
     AphrontFormView $form,
     array $values,
     array $issues) {
 
     return;
   }
 
   public function willRenderLinkedAccount(
     PhabricatorUser $viewer,
     PHUIObjectItemView $item,
     PhabricatorExternalAccount $account) {
 
     $account_view = id(new PhabricatorAuthAccountView())
       ->setExternalAccount($account)
       ->setAuthProvider($this);
 
     $item->appendChild(
       phutil_tag(
         'div',
         array(
           'class' => 'mmr mml mst mmb',
         ),
         $account_view));
   }
 
   /**
    * Return true to use a two-step configuration (setup, configure) instead of
    * the default single-step configuration. In practice, this means that
    * creating a new provider instance will redirect back to the edit page
    * instead of the provider list.
    *
    * @return bool True if this provider uses two-step configuration.
    */
   public function hasSetupStep() {
     return false;
   }
 
   /**
    * Render a standard login/register button element.
    *
    * The `$attributes` parameter takes these keys:
    *
    *   - `uri`: URI the button should take the user to when clicked.
    *   - `method`: Optional HTTP method the button should use, defaults to GET.
    *
-   * @param   AphrontRequest  HTTP request.
-   * @param   string          Request mode string.
-   * @param   map             Additional parameters, see above.
+   * @param   AphrontRequest $request HTTP request.
+   * @param   string         $mode Request mode string.
+   * @param   map?           $attributes Additional parameters, see above.
    * @return  wild            Log in button.
    */
   protected function renderStandardLoginButton(
     AphrontRequest $request,
     $mode,
     array $attributes = array()) {
 
     PhutilTypeSpec::checkMap(
       $attributes,
       array(
         'method' => 'optional string',
         'uri' => 'string',
         'sigil' => 'optional string',
       ));
 
     $viewer = $request->getUser();
     $adapter = $this->getAdapter();
 
     if ($mode == 'link') {
       $button_text = pht('Link External Account');
     } else if ($mode == 'refresh') {
       $button_text = pht('Refresh Account Link');
     } else if ($mode == 'invite') {
       $button_text = pht('Register Account');
     } else if ($this->shouldAllowRegistration()) {
       $button_text = pht('Log In or Register');
     } else {
       $button_text = pht('Log In');
     }
 
     $icon = id(new PHUIIconView())
       ->setSpriteSheet(PHUIIconView::SPRITE_LOGIN)
       ->setSpriteIcon($this->getLoginIcon());
 
     $button = id(new PHUIButtonView())
       ->setSize(PHUIButtonView::BIG)
       ->setColor(PHUIButtonView::GREY)
       ->setIcon($icon)
       ->setText($button_text)
       ->setSubtext($this->getProviderName());
 
     $uri = $attributes['uri'];
     $uri = new PhutilURI($uri);
     $params = $uri->getQueryParamsAsPairList();
     $uri->removeAllQueryParams();
 
     $content = array($button);
 
     foreach ($params as $pair) {
       list($key, $value) = $pair;
       $content[] = phutil_tag(
         'input',
         array(
           'type' => 'hidden',
           'name' => $key,
           'value' => $value,
         ));
     }
 
     $static_response = CelerityAPI::getStaticResourceResponse();
     $static_response->addContentSecurityPolicyURI('form-action', (string)$uri);
 
     foreach ($this->getContentSecurityPolicyFormActions() as $csp_uri) {
       $static_response->addContentSecurityPolicyURI('form-action', $csp_uri);
     }
 
     return phabricator_form(
       $viewer,
       array(
         'method' => idx($attributes, 'method', 'GET'),
         'action' => (string)$uri,
         'sigil'  => idx($attributes, 'sigil'),
       ),
       $content);
   }
 
   public function renderConfigurationFooter() {
     return null;
   }
 
   public function getAuthCSRFCode(AphrontRequest $request) {
     $phcid = $request->getCookie(PhabricatorCookies::COOKIE_CLIENTID);
     if (!strlen($phcid)) {
       throw new AphrontMalformedRequestException(
         pht('Missing Client ID Cookie'),
         pht(
           'Your browser did not submit a "%s" cookie with client state '.
           'information in the request. Check that cookies are enabled. '.
           'If this problem persists, you may need to clear your cookies.',
           PhabricatorCookies::COOKIE_CLIENTID),
         true);
     }
 
     return PhabricatorHash::weakDigest($phcid);
   }
 
   protected function verifyAuthCSRFCode(AphrontRequest $request, $actual) {
     $expect = $this->getAuthCSRFCode($request);
 
     if (!strlen($actual)) {
       throw new Exception(
         pht(
           'The authentication provider did not return a client state '.
           'parameter in its response, but one was expected. If this '.
           'problem persists, you may need to clear your cookies.'));
     }
 
     if (!phutil_hashes_are_identical($actual, $expect)) {
       throw new Exception(
         pht(
           'The authentication provider did not return the correct client '.
           'state parameter in its response. If this problem persists, you may '.
           'need to clear your cookies.'));
     }
   }
 
   public function supportsAutoLogin() {
     return false;
   }
 
   public function getAutoLoginURI(AphrontRequest $request) {
     throw new PhutilMethodNotImplementedException();
   }
 
   protected function getContentSecurityPolicyFormActions() {
     return array();
   }
 
 }
diff --git a/src/applications/base/PhabricatorApplication.php b/src/applications/base/PhabricatorApplication.php
index 67294db263..4433306e78 100644
--- a/src/applications/base/PhabricatorApplication.php
+++ b/src/applications/base/PhabricatorApplication.php
@@ -1,675 +1,675 @@
 <?php
 
 /**
  * @task  info  Application Information
  * @task  ui    UI Integration
  * @task  uri   URI Routing
  * @task  mail  Email integration
  * @task  fact  Fact Integration
  * @task  meta  Application Management
  */
 abstract class PhabricatorApplication
   extends PhabricatorLiskDAO
   implements
     PhabricatorPolicyInterface,
     PhabricatorApplicationTransactionInterface {
 
   const GROUP_CORE            = 'core';
   const GROUP_UTILITIES       = 'util';
   const GROUP_ADMIN           = 'admin';
   const GROUP_DEVELOPER       = 'developer';
 
   final public static function getApplicationGroups() {
     return array(
       self::GROUP_CORE          => pht('Core Applications'),
       self::GROUP_UTILITIES     => pht('Utilities'),
       self::GROUP_ADMIN         => pht('Administration'),
       self::GROUP_DEVELOPER     => pht('Developer Tools'),
     );
   }
 
   final public function getApplicationName() {
     return 'application';
   }
 
   final public function getTableName() {
     return 'application_application';
   }
 
   final protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
     ) + parent::getConfiguration();
   }
 
   final public function generatePHID() {
     return $this->getPHID();
   }
 
   final public function save() {
     // When "save()" is called on applications, we just return without
     // actually writing anything to the database.
     return $this;
   }
 
 
 /* -(  Application Information  )-------------------------------------------- */
 
   abstract public function getName();
 
   public function getShortDescription() {
     return pht('%s Application', $this->getName());
   }
 
   /**
    * Extensions are allowed to register multi-character monograms.
    * The name "Monogram" is actually a bit of a misnomer,
    * but we're keeping it due to the history.
    *
    * @return array
    */
   public function getMonograms() {
     return array();
   }
 
   public function isDeprecated() {
     return false;
   }
 
   final public function isInstalled() {
     if (!$this->canUninstall()) {
       return true;
     }
 
     $prototypes = PhabricatorEnv::getEnvConfig('phabricator.show-prototypes');
     if (!$prototypes && $this->isPrototype()) {
       return false;
     }
 
     $uninstalled = PhabricatorEnv::getEnvConfig(
       'phabricator.uninstalled-applications');
 
     return empty($uninstalled[get_class($this)]);
   }
 
 
   public function isPrototype() {
     return false;
   }
 
 
   /**
    * Return `true` if this application should never appear in application lists
    * in the UI. Primarily intended for unit test applications or other
    * pseudo-applications.
    *
    * Few applications should be unlisted. For most applications, use
    * @{method:isLaunchable} to hide them from main launch views instead.
    *
    * @return bool True to remove application from UI lists.
    */
   public function isUnlisted() {
     return false;
   }
 
 
   /**
    * Return `true` if this application is a normal application with a base
    * URI and a web interface.
    *
    * Launchable applications can be pinned to the home page, and show up in the
    * "Launcher" view of the Applications application. Making an application
    * unlaunchable prevents pinning and hides it from this view.
    *
    * Usually, an application should be marked unlaunchable if:
    *
    *   - it is available on every page anyway (like search); or
    *   - it does not have a web interface (like subscriptions); or
    *   - it is still pre-release and being intentionally buried.
    *
    * To hide applications more completely, use @{method:isUnlisted}.
    *
    * @return bool True if the application is launchable.
    */
   public function isLaunchable() {
     return true;
   }
 
 
   /**
    * Return `true` if this application should be pinned by default.
    *
    * Users who have not yet set preferences see a default list of applications.
    *
-   * @param PhabricatorUser User viewing the pinned application list.
+   * @param PhabricatorUser $viewer User viewing the pinned application list.
    * @return bool True if this application should be pinned by default.
    */
   public function isPinnedByDefault(PhabricatorUser $viewer) {
     return false;
   }
 
 
   /**
    * Returns true if an application is first-party and false otherwise.
    *
    * @return bool True if this application is first-party.
    */
   final public function isFirstParty() {
     $where = id(new ReflectionClass($this))->getFileName();
     $root = phutil_get_library_root('phabricator');
 
     if (!Filesystem::isDescendant($where, $root)) {
       return false;
     }
 
     if (Filesystem::isDescendant($where, $root.'/extensions')) {
       return false;
     }
 
     return true;
   }
 
   public function canUninstall() {
     return true;
   }
 
   final public function getPHID() {
     return 'PHID-APPS-'.get_class($this);
   }
 
   public function getTypeaheadURI() {
     return $this->isLaunchable() ? $this->getBaseURI() : null;
   }
 
   public function getBaseURI() {
     return null;
   }
 
   final public function getApplicationURI($path = '') {
     return $this->getBaseURI().ltrim($path, '/');
   }
 
   public function getIcon() {
     return 'fa-puzzle-piece';
   }
 
   public function getApplicationOrder() {
     return PHP_INT_MAX;
   }
 
   public function getApplicationGroup() {
     return self::GROUP_CORE;
   }
 
   public function getTitleGlyph() {
     return null;
   }
 
   final public function getHelpMenuItems(PhabricatorUser $viewer) {
     $items = array();
 
     $articles = $this->getHelpDocumentationArticles($viewer);
     if ($articles) {
       foreach ($articles as $article) {
         $item = id(new PhabricatorActionView())
           ->setName($article['name'])
           ->setHref($article['href'])
           ->addSigil('help-item')
           ->setOpenInNewWindow(true);
         $items[] = $item;
       }
     }
 
     $command_specs = $this->getMailCommandObjects();
     if ($command_specs) {
       foreach ($command_specs as $key => $spec) {
         $object = $spec['object'];
 
         $class = get_class($this);
         $href = '/applications/mailcommands/'.$class.'/'.$key.'/';
         $item = id(new PhabricatorActionView())
           ->setName($spec['name'])
           ->setHref($href)
           ->addSigil('help-item')
           ->setOpenInNewWindow(true);
         $items[] = $item;
       }
     }
 
     if ($items) {
       $divider = id(new PhabricatorActionView())
         ->addSigil('help-item')
         ->setType(PhabricatorActionView::TYPE_DIVIDER);
       array_unshift($items, $divider);
     }
 
     return array_values($items);
   }
 
   public function getHelpDocumentationArticles(PhabricatorUser $viewer) {
     return array();
   }
 
   /**
    * Get the Application Overview in raw Remarkup
    *
    * @return string|null
    */
   public function getOverview() {
     return null;
   }
 
   public function getEventListeners() {
     return array();
   }
 
   public function getRemarkupRules() {
     return array();
   }
 
   public function getQuicksandURIPatternBlacklist() {
     return array();
   }
 
   public function getMailCommandObjects() {
     return array();
   }
 
 
 /* -(  URI Routing  )-------------------------------------------------------- */
 
 
   public function getRoutes() {
     return array();
   }
 
   public function getResourceRoutes() {
     return array();
   }
 
 
 /* -(  Email Integration  )-------------------------------------------------- */
 
 
   public function supportsEmailIntegration() {
     return false;
   }
 
   final protected function getInboundEmailSupportLink() {
     return PhabricatorEnv::getDoclink('Configuring Inbound Email');
   }
 
   public function getAppEmailBlurb() {
     throw new PhutilMethodNotImplementedException();
   }
 
 /* -(  Fact Integration  )--------------------------------------------------- */
 
 
   public function getFactObjectsForAnalysis() {
     return array();
   }
 
 
 /* -(  UI Integration  )----------------------------------------------------- */
 
 
   /**
    * You can provide an optional piece of flavor text for the application. This
    * is currently rendered in application launch views if the application has no
    * status elements.
    *
    * @return string|null Flavor text.
    * @task ui
    */
   public function getFlavorText() {
     return null;
   }
 
 
   /**
    * Build items for the main menu.
    *
-   * @param  PhabricatorUser    The viewing user.
-   * @param  AphrontController  The current controller. May be null for special
-   *                            pages like 404, exception handlers, etc.
+   * @param  PhabricatorUser    $user The viewing user.
+   * @param  AphrontController? $controller The current controller. May be null
+   *   for special pages like 404, exception handlers, etc.
    * @return list<PHUIListItemView> List of menu items.
    * @task ui
    */
   public function buildMainMenuItems(
     PhabricatorUser $user,
     PhabricatorController $controller = null) {
     return array();
   }
 
 
 /* -(  Application Management  )--------------------------------------------- */
 
 
   final public static function getByClass($class_name) {
     $selected = null;
     $applications = self::getAllApplications();
 
     foreach ($applications as $application) {
       if (get_class($application) == $class_name) {
         $selected = $application;
         break;
       }
     }
 
     if (!$selected) {
       throw new Exception(pht("No application '%s'!", $class_name));
     }
 
     return $selected;
   }
 
   final public static function getAllApplications() {
     static $applications;
 
     if ($applications === null) {
       $apps = id(new PhutilClassMapQuery())
         ->setAncestorClass(__CLASS__)
         ->setSortMethod('getApplicationOrder')
         ->execute();
 
       // Reorder the applications into "application order". Notably, this
       // ensures their event handlers register in application order.
       $apps = mgroup($apps, 'getApplicationGroup');
 
       $group_order = array_keys(self::getApplicationGroups());
       $apps = array_select_keys($apps, $group_order) + $apps;
 
       $apps = array_mergev($apps);
 
       $applications = $apps;
     }
 
     return $applications;
   }
 
   final public static function getAllInstalledApplications() {
     $all_applications = self::getAllApplications();
     $apps = array();
     foreach ($all_applications as $app) {
       if (!$app->isInstalled()) {
         continue;
       }
 
       $apps[] = $app;
     }
 
     return $apps;
   }
 
 
   /**
    * Determine if an application is installed, by application class name.
    *
    * To check if an application is installed //and// available to a particular
    * viewer, user @{method:isClassInstalledForViewer}.
    *
-   * @param string  Application class name.
+   * @param string $class Application class name.
    * @return bool   True if the class is installed.
    * @task meta
    */
   final public static function isClassInstalled($class) {
     return self::getByClass($class)->isInstalled();
   }
 
 
   /**
    * Determine if an application is installed and available to a viewer, by
    * application class name.
    *
    * To check if an application is installed at all, use
    * @{method:isClassInstalled}.
    *
-   * @param string Application class name.
-   * @param PhabricatorUser Viewing user.
+   * @param string $class Application class name.
+   * @param PhabricatorUser $viewer Viewing user.
    * @return bool True if the class is installed for the viewer.
    * @task meta
    */
   final public static function isClassInstalledForViewer(
     $class,
     PhabricatorUser $viewer) {
 
     if ($viewer->isOmnipotent()) {
       return true;
     }
 
     $cache = PhabricatorCaches::getRequestCache();
     $viewer_fragment = $viewer->getCacheFragment();
     $key = 'app.'.$class.'.installed.'.$viewer_fragment;
 
     $result = $cache->getKey($key);
     if ($result === null) {
       if (!self::isClassInstalled($class)) {
         $result = false;
       } else {
         $application = self::getByClass($class);
         if (!$application->canUninstall()) {
           // If the application can not be uninstalled, always allow viewers
           // to see it. In particular, this allows logged-out viewers to see
           // Settings and load global default settings even if the install
           // does not allow public viewers.
           $result = true;
         } else {
           $result = PhabricatorPolicyFilter::hasCapability(
             $viewer,
             self::getByClass($class),
             PhabricatorPolicyCapability::CAN_VIEW);
         }
       }
 
       $cache->setKey($key, $result);
     }
 
     return $result;
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array_merge(
       array(
         PhabricatorPolicyCapability::CAN_VIEW,
         PhabricatorPolicyCapability::CAN_EDIT,
       ),
       array_keys($this->getCustomCapabilities()));
   }
 
   public function getPolicy($capability) {
     $default = $this->getCustomPolicySetting($capability);
     if ($default) {
       return $default;
     }
 
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         return PhabricatorPolicies::getMostOpenPolicy();
       case PhabricatorPolicyCapability::CAN_EDIT:
         return PhabricatorPolicies::POLICY_ADMIN;
       default:
         $spec = $this->getCustomCapabilitySpecification($capability);
         return idx($spec, 'default', PhabricatorPolicies::POLICY_USER);
     }
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return false;
   }
 
 
 /* -(  Policies  )----------------------------------------------------------- */
 
   protected function getCustomCapabilities() {
     return array();
   }
 
   private function getCustomPolicySetting($capability) {
     if (!$this->isCapabilityEditable($capability)) {
       return null;
     }
 
     $policy_locked = PhabricatorEnv::getEnvConfig('policy.locked');
     if (isset($policy_locked[$capability])) {
       return $policy_locked[$capability];
     }
 
     $config = PhabricatorEnv::getEnvConfig('phabricator.application-settings');
 
     $app = idx($config, $this->getPHID());
     if (!$app) {
       return null;
     }
 
     $policy = idx($app, 'policy');
     if (!$policy) {
       return null;
     }
 
     return idx($policy, $capability);
   }
 
 
   private function getCustomCapabilitySpecification($capability) {
     $custom = $this->getCustomCapabilities();
     if (!isset($custom[$capability])) {
       throw new Exception(pht("Unknown capability '%s'!", $capability));
     }
     return $custom[$capability];
   }
 
   final public function getCapabilityLabel($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         return pht('Can Use Application');
       case PhabricatorPolicyCapability::CAN_EDIT:
         return pht('Can Configure Application');
     }
 
     $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability);
     if ($capobj) {
       return $capobj->getCapabilityName();
     }
 
     return null;
   }
 
   final public function isCapabilityEditable($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         return $this->canUninstall();
       case PhabricatorPolicyCapability::CAN_EDIT:
         return true;
       default:
         $spec = $this->getCustomCapabilitySpecification($capability);
         return idx($spec, 'edit', true);
     }
   }
 
   final public function getCapabilityCaption($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         if (!$this->canUninstall()) {
           return pht(
             'This application is required, so all '.
             'users must have access to it.');
         } else {
           return null;
         }
       case PhabricatorPolicyCapability::CAN_EDIT:
         return null;
       default:
         $spec = $this->getCustomCapabilitySpecification($capability);
         return idx($spec, 'caption');
     }
   }
 
   final public function getCapabilityTemplatePHIDType($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
       case PhabricatorPolicyCapability::CAN_EDIT:
         return null;
     }
 
     $spec = $this->getCustomCapabilitySpecification($capability);
     return idx($spec, 'template');
   }
 
   final public function getDefaultObjectTypePolicyMap() {
     $map = array();
 
     foreach ($this->getCustomCapabilities() as $capability => $spec) {
       if (empty($spec['template'])) {
         continue;
       }
       if (empty($spec['capability'])) {
         continue;
       }
       $default = $this->getPolicy($capability);
       $map[$spec['template']][$spec['capability']] = $default;
     }
 
     return $map;
   }
 
   public function getApplicationSearchDocumentTypes() {
     return array();
   }
 
   protected function getEditRoutePattern($base = null) {
     return $base.'(?:'.
       '(?P<id>[0-9]\d*)/)?'.
       '(?:'.
         '(?:'.
           '(?P<editAction>parameters|nodefault|nocreate|nomanage|comment)/'.
           '|'.
           '(?:form/(?P<formKey>[^/]+)/)?(?:page/(?P<pageKey>[^/]+)/)?'.
         ')'.
       ')?';
   }
 
   protected function getBulkRoutePattern($base = null) {
     return $base.'(?:query/(?P<queryKey>[^/]+)/)?';
   }
 
   protected function getQueryRoutePattern($base = null) {
     return $base.'(?:query/(?P<queryKey>[^/]+)/(?:(?P<queryAction>[^/]+)/)?)?';
   }
 
   protected function getProfileMenuRouting($controller) {
     $edit_route = $this->getEditRoutePattern();
 
     $mode_route = '(?P<itemEditMode>global|custom)/';
 
     return array(
       '(?P<itemAction>view)/(?P<itemID>[^/]+)/' => $controller,
       '(?P<itemAction>hide)/(?P<itemID>[^/]+)/' => $controller,
       '(?P<itemAction>default)/(?P<itemID>[^/]+)/' => $controller,
       '(?P<itemAction>configure)/' => $controller,
       '(?P<itemAction>configure)/'.$mode_route => $controller,
       '(?P<itemAction>reorder)/'.$mode_route => $controller,
       '(?P<itemAction>edit)/'.$edit_route => $controller,
       '(?P<itemAction>new)/'.$mode_route.'(?<itemKey>[^/]+)/'.$edit_route
         => $controller,
       '(?P<itemAction>builtin)/(?<itemID>[^/]+)/'.$edit_route
         => $controller,
     );
   }
 
 /* -(  PhabricatorApplicationTransactionInterface  )------------------------- */
 
 
   public function getApplicationTransactionEditor() {
     return new PhabricatorApplicationEditor();
   }
 
   public function getApplicationTransactionTemplate() {
     return new PhabricatorApplicationApplicationTransaction();
   }
 
 }
diff --git a/src/applications/cache/PhabricatorCaches.php b/src/applications/cache/PhabricatorCaches.php
index 9c22bde672..8e45741fe6 100644
--- a/src/applications/cache/PhabricatorCaches.php
+++ b/src/applications/cache/PhabricatorCaches.php
@@ -1,471 +1,471 @@
 <?php
 
 /**
  *
  * @task request    Request Cache
  * @task immutable  Immutable Cache
  * @task setup      Setup Cache
  * @task compress   Compression
  */
 final class PhabricatorCaches extends Phobject {
 
   private static $requestCache;
 
   public static function getNamespace() {
     return PhabricatorEnv::getEnvConfig('phabricator.cache-namespace');
   }
 
   private static function newStackFromCaches(array $caches) {
     $caches = self::addNamespaceToCaches($caches);
     $caches = self::addProfilerToCaches($caches);
     return id(new PhutilKeyValueCacheStack())
       ->setCaches($caches);
   }
 
 /* -(  Request Cache  )------------------------------------------------------ */
 
 
   /**
    * Get a request cache stack.
    *
    * This cache stack is destroyed after each logical request. In particular,
    * it is destroyed periodically by the daemons, while `static` caches are
    * not.
    *
    * @return PhutilKeyValueCacheStack Request cache stack.
    */
   public static function getRequestCache() {
     if (!self::$requestCache) {
       self::$requestCache = new PhutilInRequestKeyValueCache();
     }
     return self::$requestCache;
   }
 
 
   /**
    * Destroy the request cache.
    *
    * This is called at the beginning of each logical request.
    *
    * @return void
    */
   public static function destroyRequestCache() {
     self::$requestCache = null;
 
     // See T12997. Force the GC to run when the request cache is destroyed to
     // clean up any cycles which may still be hanging around.
     if (function_exists('gc_collect_cycles')) {
       gc_collect_cycles();
     }
   }
 
 
 /* -(  Immutable Cache  )---------------------------------------------------- */
 
 
   /**
    * Gets an immutable cache stack.
    *
    * This stack trades mutability away for improved performance. Normally, it is
    * APC + DB.
    *
    * In the general case with multiple web frontends, this stack can not be
    * cleared, so it is only appropriate for use if the value of a given key is
    * permanent and immutable.
    *
    * @return PhutilKeyValueCacheStack Best immutable stack available.
    * @task immutable
    */
   public static function getImmutableCache() {
     static $cache;
     if (!$cache) {
       $caches = self::buildImmutableCaches();
       $cache = self::newStackFromCaches($caches);
     }
     return $cache;
   }
 
 
   /**
    * Build the immutable cache stack.
    *
    * @return list<PhutilKeyValueCache> List of caches.
    * @task immutable
    */
   private static function buildImmutableCaches() {
     $caches = array();
 
     $apc = new PhutilAPCKeyValueCache();
     if ($apc->isAvailable()) {
       $caches[] = $apc;
     }
 
     $caches[] = new PhabricatorKeyValueDatabaseCache();
 
     return $caches;
   }
 
   public static function getMutableCache() {
     static $cache;
     if (!$cache) {
       $caches = self::buildMutableCaches();
       $cache = self::newStackFromCaches($caches);
     }
     return $cache;
   }
 
   private static function buildMutableCaches() {
     $caches = array();
 
     $caches[] = new PhabricatorKeyValueDatabaseCache();
 
     return $caches;
   }
 
   public static function getMutableStructureCache() {
     static $cache;
     if (!$cache) {
       $caches = self::buildMutableStructureCaches();
       $cache = self::newStackFromCaches($caches);
     }
     return $cache;
   }
 
   private static function buildMutableStructureCaches() {
     $caches = array();
 
     $cache = new PhabricatorKeyValueDatabaseCache();
     $cache = new PhabricatorKeyValueSerializingCacheProxy($cache);
     $caches[] = $cache;
 
     return $caches;
   }
 
 /* -(  Runtime Cache  )------------------------------------------------------ */
 
 
   /**
    * Get a runtime cache stack.
    *
    * This stack is just APC. It's fast, it's effectively immutable, and it
    * gets thrown away when the webserver restarts.
    *
    * This cache is suitable for deriving runtime caches, like a map of Conduit
    * method names to provider classes.
    *
    * @return PhutilKeyValueCacheStack Best runtime stack available.
    */
   public static function getRuntimeCache() {
     static $cache;
     if (!$cache) {
       $caches = self::buildRuntimeCaches();
       $cache = self::newStackFromCaches($caches);
     }
     return $cache;
   }
 
 
   private static function buildRuntimeCaches() {
     $caches = array();
 
     $apc = new PhutilAPCKeyValueCache();
     if ($apc->isAvailable()) {
       $caches[] = $apc;
     }
 
     return $caches;
   }
 
 
 /* -(  Repository Graph Cache  )--------------------------------------------- */
 
 
   public static function getRepositoryGraphL1Cache() {
     static $cache;
     if (!$cache) {
       $caches = self::buildRepositoryGraphL1Caches();
       $cache = self::newStackFromCaches($caches);
     }
     return $cache;
   }
 
   private static function buildRepositoryGraphL1Caches() {
     $caches = array();
 
     $request = new PhutilInRequestKeyValueCache();
     $request->setLimit(32);
     $caches[] = $request;
 
     $apc = new PhutilAPCKeyValueCache();
     if ($apc->isAvailable()) {
       $caches[] = $apc;
     }
 
     return $caches;
   }
 
   public static function getRepositoryGraphL2Cache() {
     static $cache;
     if (!$cache) {
       $caches = self::buildRepositoryGraphL2Caches();
       $cache = self::newStackFromCaches($caches);
     }
     return $cache;
   }
 
   private static function buildRepositoryGraphL2Caches() {
     $caches = array();
     $caches[] = new PhabricatorKeyValueDatabaseCache();
     return $caches;
   }
 
 
 /* -(  Server State Cache  )------------------------------------------------- */
 
 
   /**
    * Highly specialized cache for storing server process state.
    *
    * We use this cache to track initial steps in the setup phase, before
    * configuration is loaded.
    *
    * This cache does NOT use the cache namespace (it must be accessed before
    * we build configuration), and is global across all instances on the host.
    *
    * @return PhutilKeyValueCacheStack Best available server state cache stack.
    * @task setup
    */
   public static function getServerStateCache() {
     static $cache;
     if (!$cache) {
       $caches = self::buildSetupCaches('phabricator-server');
 
       // NOTE: We are NOT adding a cache namespace here! This cache is shared
       // across all instances on the host.
 
       $caches = self::addProfilerToCaches($caches);
       $cache = id(new PhutilKeyValueCacheStack())
         ->setCaches($caches);
 
     }
     return $cache;
   }
 
 
 
 /* -(  Setup Cache  )-------------------------------------------------------- */
 
 
   /**
    * Highly specialized cache for performing setup checks. We use this cache
    * to determine if we need to run expensive setup checks when the page
    * loads. Without it, we would need to run these checks every time.
    *
    * Normally, this cache is just APC. In the absence of APC, this cache
    * degrades into a slow, quirky on-disk cache.
    *
    * NOTE: Do not use this cache for anything else! It is not a general-purpose
    * cache!
    *
    * @return PhutilKeyValueCacheStack Most qualified available cache stack.
    * @task setup
    */
   public static function getSetupCache() {
     static $cache;
     if (!$cache) {
       $caches = self::buildSetupCaches('phabricator-setup');
       $cache = self::newStackFromCaches($caches);
     }
     return $cache;
   }
 
 
   /**
    * @task setup
    */
   private static function buildSetupCaches($cache_name) {
     // If this is the CLI, just build a setup cache.
     if (php_sapi_name() == 'cli') {
       return array();
     }
 
     // In most cases, we should have APC. This is an ideal cache for our
     // purposes -- it's fast and empties on server restart.
     $apc = new PhutilAPCKeyValueCache();
     if ($apc->isAvailable()) {
       return array($apc);
     }
 
     // If we don't have APC, build a poor approximation on disk. This is still
     // much better than nothing; some setup steps are quite slow.
     $disk_path = self::getSetupCacheDiskCachePath($cache_name);
     if ($disk_path) {
       $disk = new PhutilOnDiskKeyValueCache();
       $disk->setCacheFile($disk_path);
       $disk->setWait(0.1);
       if ($disk->isAvailable()) {
         return array($disk);
       }
     }
 
     return array();
   }
 
 
   /**
    * @task setup
    */
   private static function getSetupCacheDiskCachePath($name) {
     // The difficulty here is in choosing a path which will change on server
     // restart (we MUST have this property), but as rarely as possible
     // otherwise (we desire this property to give the cache the best hit rate
     // we can).
 
     // Unfortunately, we don't have a very good strategy for minimizing the
     // churn rate of the cache. We previously tried to use the parent process
     // PID in some cases, but this was not reliable. See T9599 for one case of
     // this.
 
     $pid_basis = getmypid();
 
     // If possible, we also want to know when the process launched, so we can
     // drop the cache if a process restarts but gets the same PID an earlier
     // process had. "/proc" is not available everywhere (e.g., not on OSX), but
     // check if we have it.
     $epoch_basis = null;
     $stat = @stat("/proc/{$pid_basis}");
     if ($stat !== false) {
       $epoch_basis = $stat['ctime'];
     }
 
     $tmp_dir = sys_get_temp_dir();
 
     $tmp_path = $tmp_dir.DIRECTORY_SEPARATOR.$name;
     if (!file_exists($tmp_path)) {
       @mkdir($tmp_path);
     }
 
     $is_ok = self::testTemporaryDirectory($tmp_path);
     if (!$is_ok) {
       $tmp_path = $tmp_dir;
       $is_ok = self::testTemporaryDirectory($tmp_path);
       if (!$is_ok) {
         // We can't find anywhere to write the cache, so just bail.
         return null;
       }
     }
 
     $tmp_name = 'setup-'.$pid_basis;
     if ($epoch_basis) {
       $tmp_name .= '.'.$epoch_basis;
     }
     $tmp_name .= '.cache';
 
     return $tmp_path.DIRECTORY_SEPARATOR.$tmp_name;
   }
 
 
   /**
    * @task setup
    */
   private static function testTemporaryDirectory($dir) {
     if (!@file_exists($dir)) {
       return false;
     }
     if (!@is_dir($dir)) {
       return false;
     }
     if (!@is_writable($dir)) {
       return false;
     }
 
     return true;
   }
 
   private static function addProfilerToCaches(array $caches) {
     foreach ($caches as $key => $cache) {
       $pcache = new PhutilKeyValueCacheProfiler($cache);
       $pcache->setProfiler(PhutilServiceProfiler::getInstance());
       $caches[$key] = $pcache;
     }
     return $caches;
   }
 
   private static function addNamespaceToCaches(array $caches) {
     $namespace = self::getNamespace();
     if (!$namespace) {
       return $caches;
     }
 
     foreach ($caches as $key => $cache) {
       $ncache = new PhutilKeyValueCacheNamespace($cache);
       $ncache->setNamespace($namespace);
       $caches[$key] = $ncache;
     }
 
     return $caches;
   }
 
 
   /**
    * Deflate a value, if deflation is available and has an impact.
    *
    * If the value is larger than 1KB, we have `gzdeflate()`, we successfully
    * can deflate it, and it benefits from deflation, we deflate it. Otherwise
    * we leave it as-is.
    *
    * Data can later be inflated with @{method:inflateData}.
    *
-   * @param string String to attempt to deflate.
+   * @param string $value String to attempt to deflate.
    * @return string|null Deflated string, or null if it was not deflated.
    * @task compress
    */
   public static function maybeDeflateData($value) {
     $len = strlen($value);
     if ($len <= 1024) {
       return null;
     }
 
     if (!function_exists('gzdeflate')) {
       return null;
     }
 
     $deflated = gzdeflate($value);
     if ($deflated === false) {
       return null;
     }
 
     $deflated_len = strlen($deflated);
     if ($deflated_len >= ($len / 2)) {
       return null;
     }
 
     return $deflated;
   }
 
 
   /**
    * Inflate data previously deflated by @{method:maybeDeflateData}.
    *
-   * @param string Deflated data, from @{method:maybeDeflateData}.
+   * @param string $value Deflated data, from @{method:maybeDeflateData}.
    * @return string Original, uncompressed data.
    * @task compress
    */
   public static function inflateData($value) {
     if (!function_exists('gzinflate')) {
       throw new Exception(
         pht(
           '%s is not available; unable to read deflated data!',
           'gzinflate()'));
     }
 
     $value = gzinflate($value);
     if ($value === false) {
       throw new Exception(pht('Failed to inflate data!'));
     }
 
     return $value;
   }
 
 
 }
diff --git a/src/applications/celerity/CelerityResourceMap.php b/src/applications/celerity/CelerityResourceMap.php
index e53b656aaf..58036d9016 100644
--- a/src/applications/celerity/CelerityResourceMap.php
+++ b/src/applications/celerity/CelerityResourceMap.php
@@ -1,266 +1,266 @@
 <?php
 
 /**
  * Interface to the static resource map, which is a graph of available
  * resources, resource dependencies, and packaging information. You generally do
  * not need to invoke it directly; instead, you call higher-level Celerity APIs
  * and it uses the resource map to satisfy your requests.
  */
 final class CelerityResourceMap extends Phobject {
 
   private static $instances = array();
 
   private $resources;
   private $symbolMap;
   private $requiresMap;
   private $packageMap;
   private $nameMap;
   private $hashMap;
   private $componentMap;
 
   public function __construct(CelerityResources $resources) {
     $this->resources = $resources;
 
     $map = $resources->loadMap();
     $this->symbolMap = idx($map, 'symbols', array());
     $this->requiresMap = idx($map, 'requires', array());
     $this->packageMap = idx($map, 'packages', array());
     $this->nameMap = idx($map, 'names', array());
 
     // We derive these reverse maps at runtime.
 
     $this->hashMap = array_flip($this->nameMap);
     $this->componentMap = array();
     foreach ($this->packageMap as $package_name => $symbols) {
       foreach ($symbols as $symbol) {
         $this->componentMap[$symbol] = $package_name;
       }
     }
   }
 
   public static function getNamedInstance($name) {
     if (empty(self::$instances[$name])) {
       $resources_list = CelerityPhysicalResources::getAll();
       if (empty($resources_list[$name])) {
         throw new Exception(
           pht(
             'No resource source exists with name "%s"!',
             $name));
       }
 
       $instance = new CelerityResourceMap($resources_list[$name]);
       self::$instances[$name] = $instance;
     }
 
     return self::$instances[$name];
   }
 
   public function getNameMap() {
     return $this->nameMap;
   }
 
   public function getSymbolMap() {
     return $this->symbolMap;
   }
 
   public function getRequiresMap() {
     return $this->requiresMap;
   }
 
   public function getPackageMap() {
     return $this->packageMap;
   }
 
   public function getPackagedNamesForSymbols(array $symbols) {
     $resolved = $this->resolveResources($symbols);
     return $this->packageResources($resolved);
   }
 
   private function resolveResources(array $symbols) {
     $map = array();
     foreach ($symbols as $symbol) {
       if (!empty($map[$symbol])) {
         continue;
       }
       $this->resolveResource($map, $symbol);
     }
 
     return $map;
   }
 
   private function resolveResource(array &$map, $symbol) {
     if (empty($this->symbolMap[$symbol])) {
       throw new Exception(
         pht(
           'Attempting to resolve unknown resource, "%s".',
           $symbol));
     }
 
     $hash = $this->symbolMap[$symbol];
 
     $map[$symbol] = $hash;
 
     if (isset($this->requiresMap[$hash])) {
       $requires = $this->requiresMap[$hash];
     } else {
       $requires = array();
     }
 
     foreach ($requires as $required_symbol) {
       if (!empty($map[$required_symbol])) {
         continue;
       }
       $this->resolveResource($map, $required_symbol);
     }
   }
 
   private function packageResources(array $resolved_map) {
     $packaged = array();
     $handled = array();
     foreach ($resolved_map as $symbol => $hash) {
       if (isset($handled[$symbol])) {
         continue;
       }
 
       if (empty($this->componentMap[$symbol])) {
         $packaged[] = $this->hashMap[$hash];
       } else {
         $package_name = $this->componentMap[$symbol];
         $packaged[] = $package_name;
 
         $package_symbols = $this->packageMap[$package_name];
         foreach ($package_symbols as $package_symbol) {
           $handled[$package_symbol] = true;
         }
       }
     }
 
     return $packaged;
   }
 
   public function getResourceDataForName($resource_name) {
     return $this->resources->getResourceData($resource_name);
   }
 
   public function getResourceNamesForPackageName($package_name) {
     $package_symbols = idx($this->packageMap, $package_name);
     if (!$package_symbols) {
       return null;
     }
 
     $resource_names = array();
     foreach ($package_symbols as $symbol) {
       $resource_names[] = $this->hashMap[$this->symbolMap[$symbol]];
     }
 
     return $resource_names;
   }
 
 
   /**
    * Get the epoch timestamp of the last modification time of a symbol.
    *
-   * @param string Resource symbol to lookup.
+   * @param string $name Resource symbol to lookup.
    * @return int Epoch timestamp of last resource modification.
    */
   public function getModifiedTimeForName($name) {
     if ($this->isPackageResource($name)) {
       $names = array();
       foreach ($this->packageMap[$name] as $symbol) {
         $names[] = $this->getResourceNameForSymbol($symbol);
       }
     } else {
       $names = array($name);
     }
 
     $mtime = 0;
     foreach ($names as $name) {
       $mtime = max($mtime, $this->resources->getResourceModifiedTime($name));
     }
 
     return $mtime;
   }
 
 
   /**
    * Return the absolute URI for the resource associated with a symbol. This
    * method is fairly low-level and ignores packaging.
    *
-   * @param string Resource symbol to lookup.
+   * @param string $symbol Resource symbol to lookup.
    * @return string|null Resource URI, or null if the symbol is unknown.
    */
   public function getURIForSymbol($symbol) {
     $hash = idx($this->symbolMap, $symbol);
     return $this->getURIForHash($hash);
   }
 
 
   /**
    * Return the absolute URI for the resource associated with a resource name.
    * This method is fairly low-level and ignores packaging.
    *
-   * @param string Resource name to lookup.
+   * @param string $name Resource name to lookup.
    * @return string|null  Resource URI, or null if the name is unknown.
    */
   public function getURIForName($name) {
     $hash = idx($this->nameMap, $name);
     return $this->getURIForHash($hash);
   }
 
 
   public function getHashForName($name) {
     return idx($this->nameMap, $name);
   }
 
 
   /**
    * Return the absolute URI for a resource, identified by hash.
    * This method is fairly low-level and ignores packaging.
    *
-   * @param string Resource hash to lookup.
+   * @param string $hash Resource hash to lookup.
    * @return string|null Resource URI, or null if the hash is unknown.
    */
   private function getURIForHash($hash) {
     if ($hash === null) {
       return null;
     }
     return $this->resources->getResourceURI($hash, $this->hashMap[$hash]);
   }
 
 
   /**
    * Return the resource symbols required by a named resource.
    *
-   * @param string Resource name to lookup.
+   * @param string $name Resource name to lookup.
    * @return list<string>|null  List of required symbols, or null if the name
    *                            is unknown.
    */
   public function getRequiredSymbolsForName($name) {
     $hash = idx($this->nameMap, $name);
     if ($hash === null) {
       return null;
     }
     return idx($this->requiresMap, $hash, array());
   }
 
 
   /**
    * Return the resource name for a given symbol.
    *
-   * @param string Resource symbol to lookup.
+   * @param string $symbol Resource symbol to lookup.
    * @return string|null Resource name, or null if the symbol is unknown.
    */
   public function getResourceNameForSymbol($symbol) {
     $hash = idx($this->symbolMap, $symbol);
     return idx($this->hashMap, $hash);
   }
 
   public function isPackageResource($name) {
     return isset($this->packageMap[$name]);
   }
 
   public function getResourceTypeForName($name) {
     return $this->resources->getResourceType($name);
   }
 
 }
diff --git a/src/applications/celerity/CelerityResourceMapGenerator.php b/src/applications/celerity/CelerityResourceMapGenerator.php
index 9280c9ecc2..d54aa572ed 100644
--- a/src/applications/celerity/CelerityResourceMapGenerator.php
+++ b/src/applications/celerity/CelerityResourceMapGenerator.php
@@ -1,407 +1,410 @@
 <?php
 
 final class CelerityResourceMapGenerator extends Phobject {
 
   private $debug = false;
   private $resources;
 
   private $nameMap     = array();
   private $symbolMap   = array();
   private $requiresMap = array();
   private $packageMap  = array();
 
   public function __construct(CelerityPhysicalResources $resources) {
     $this->resources = $resources;
   }
 
   public function getNameMap() {
     return $this->nameMap;
   }
 
   public function getSymbolMap() {
     return $this->symbolMap;
   }
 
   public function getRequiresMap() {
     return $this->requiresMap;
   }
 
   public function getPackageMap() {
     return $this->packageMap;
   }
 
   public function setDebug($debug) {
     $this->debug = $debug;
     return $this;
   }
 
   protected function log($message) {
     if ($this->debug) {
       $console = PhutilConsole::getConsole();
       $console->writeErr("%s\n", $message);
     }
   }
 
   public function generate() {
     $binary_map = $this->rebuildBinaryResources($this->resources);
 
     $this->log(pht('Found %d binary resources.', count($binary_map)));
 
     $xformer = id(new CelerityResourceTransformer())
       ->setMinify(false)
       ->setRawURIMap(ipull($binary_map, 'uri'));
 
     $text_map = $this->rebuildTextResources($this->resources, $xformer);
 
     $this->log(pht('Found %d text resources.', count($text_map)));
 
     $resource_graph = array();
     $requires_map = array();
     $symbol_map = array();
     foreach ($text_map as $name => $info) {
       if (isset($info['provides'])) {
         $symbol_map[$info['provides']] = $info['hash'];
 
         // We only need to check for cycles and add this to the requires map
         // if it actually requires anything.
         if (!empty($info['requires'])) {
           $resource_graph[$info['provides']] = $info['requires'];
           $requires_map[$info['hash']] = $info['requires'];
         }
       }
     }
 
     $this->detectGraphCycles($resource_graph);
     $name_map = ipull($binary_map, 'hash') + ipull($text_map, 'hash');
     $hash_map = array_flip($name_map);
 
     $package_map = $this->rebuildPackages(
       $this->resources,
       $symbol_map,
       $hash_map);
 
     $this->log(pht('Found %d packages.', count($package_map)));
 
     $component_map = array();
     foreach ($package_map as $package_name => $package_info) {
       foreach ($package_info['symbols'] as $symbol) {
         $component_map[$symbol] = $package_name;
       }
     }
 
     $name_map = $this->mergeNameMaps(
       array(
         array(pht('Binary'), ipull($binary_map, 'hash')),
         array(pht('Text'), ipull($text_map, 'hash')),
         array(pht('Package'), ipull($package_map, 'hash')),
       ));
     $package_map = ipull($package_map, 'symbols');
 
     ksort($name_map, SORT_STRING);
     ksort($symbol_map, SORT_STRING);
     ksort($requires_map, SORT_STRING);
     ksort($package_map, SORT_STRING);
 
     $this->nameMap     = $name_map;
     $this->symbolMap   = $symbol_map;
     $this->requiresMap = $requires_map;
     $this->packageMap  = $package_map;
 
     return $this;
   }
 
   public function write() {
     $map_content = $this->formatMapContent(array(
       'names'    => $this->getNameMap(),
       'symbols'  => $this->getSymbolMap(),
       'requires' => $this->getRequiresMap(),
       'packages' => $this->getPackageMap(),
     ));
 
     $map_path = $this->resources->getPathToMap();
     $this->log(pht('Writing map "%s".', Filesystem::readablePath($map_path)));
     Filesystem::writeFile($map_path, $map_content);
 
     return $this;
   }
 
   private function formatMapContent(array $data) {
     $content = phutil_var_export($data);
     $generated = '@'.'generated';
 
     return <<<EOFILE
 <?php
 
 /**
  * This file is automatically generated. Use 'bin/celerity map' to rebuild it.
  *
  * {$generated}
  */
 return {$content};
 
 EOFILE;
   }
 
   /**
    * Find binary resources (like PNG and SWF) and return information about
    * them.
    *
-   * @param CelerityPhysicalResources Resource map to find binary resources for.
+   * @param CelerityPhysicalResources $resources Resource map to find binary
+   *                                  resources for.
    * @return map<string, map<string, string>> Resource information map.
    */
   private function rebuildBinaryResources(
     CelerityPhysicalResources $resources) {
 
     $binary_map = $resources->findBinaryResources();
     $result_map = array();
 
     foreach ($binary_map as $name => $data_hash) {
       $hash = $this->newResourceHash($data_hash.$name);
 
       $result_map[$name] = array(
         'hash' => $hash,
         'uri'  => $resources->getResourceURI($hash, $name),
       );
     }
 
     return $result_map;
   }
 
   /**
    * Find text resources (like JS and CSS) and return information about them.
    *
-   * @param CelerityPhysicalResources Resource map to find text resources for.
-   * @param CelerityResourceTransformer Configured resource transformer.
+   * @param CelerityPhysicalResources $resources Resource map to find text
+   *                                  resources for.
+   * @param CelerityResourceTransformer $xformer Configured resource
+   *                                    transformer.
    * @return map<string, map<string, string>> Resource information map.
    */
   private function rebuildTextResources(
     CelerityPhysicalResources $resources,
     CelerityResourceTransformer $xformer) {
 
     $text_map = $resources->findTextResources();
     $result_map = array();
 
     foreach ($text_map as $name => $data_hash) {
       $raw_data = $resources->getResourceData($name);
       $xformed_data = $xformer->transformResource($name, $raw_data);
 
       $data_hash = $this->newResourceHash($xformed_data);
       $hash = $this->newResourceHash($data_hash.$name);
 
       list($provides, $requires) = $this->getProvidesAndRequires(
         $name,
         $raw_data);
 
       $result_map[$name] = array(
         'hash' => $hash,
       );
 
       if ($provides !== null) {
         $result_map[$name] += array(
           'provides' => $provides,
           'requires' => $requires,
         );
       }
     }
 
     return $result_map;
   }
 
   /**
    * Parse the `@provides` and `@requires` symbols out of a text resource, like
    * JS or CSS.
    *
-   * @param string Resource name.
-   * @param string Resource data.
+   * @param string $name Resource name.
+   * @param string $data Resource data.
    * @return pair<string|null, list<string>|null> The `@provides` symbol and
    *    the list of `@requires` symbols. If the resource is not part of the
    *    dependency graph, both are null.
    */
   private function getProvidesAndRequires($name, $data) {
     $parser = new PhutilDocblockParser();
 
     $matches = array();
     $ok = preg_match('@/[*][*].*?[*]/@s', $data, $matches);
     if (!$ok) {
       throw new Exception(
         pht(
           'Resource "%s" does not have a header doc comment. Encode '.
           'dependency data in a header docblock.',
           $name));
     }
 
     list($description, $metadata) = $parser->parse($matches[0]);
 
     $provides = $this->parseResourceSymbolList(idx($metadata, 'provides'));
     $requires = $this->parseResourceSymbolList(idx($metadata, 'requires'));
     if (!$provides) {
       // Tests and documentation-only JS is permitted to @provide no targets.
       return array(null, null);
     }
 
     if (count($provides) > 1) {
       throw new Exception(
         pht(
           'Resource "%s" must %s at most one Celerity target.',
           $name,
           '@provide'));
     }
 
     return array(head($provides), $requires);
   }
 
   /**
    * Check for dependency cycles in the resource graph. Raises an exception if
    * a cycle is detected.
    *
-   * @param map<string, list<string>> Map of `@provides` symbols to their
-   *                                  `@requires` symbols.
+   * @param map<string, list<string>> $nodes Map of `@provides` symbols to
+   *                                  their `@requires` symbols.
    * @return void
    */
   private function detectGraphCycles(array $nodes) {
     $graph = id(new CelerityResourceGraph())
       ->addNodes($nodes)
       ->setResourceGraph($nodes)
       ->loadGraph();
 
     foreach ($nodes as $provides => $requires) {
       $cycle = $graph->detectCycles($provides);
       if ($cycle) {
         throw new Exception(
           pht(
             'Cycle detected in resource graph: %s',
             implode(' > ', $cycle)));
       }
     }
   }
 
   /**
    * Build package specifications for a given resource source.
    *
-   * @param CelerityPhysicalResources Resource source to rebuild.
-   * @param map<string, string> Map of `@provides` to hashes.
-   * @param map<string, string> Map of hashes to resource names.
+   * @param CelerityPhysicalResources $resources Resource source to rebuild.
+   * @param map<string, string> $symbol_map Map of `@provides` to hashes.
+   * @param map<string, string> $reverse_map Map of hashes to resource names.
    * @return map<string, map<string, string>> Package information maps.
    */
   private function rebuildPackages(
     CelerityPhysicalResources $resources,
     array $symbol_map,
     array $reverse_map) {
 
     $package_map = array();
 
     $package_spec = $resources->getResourcePackages();
     foreach ($package_spec as $package_name => $package_symbols) {
       $type = null;
       $hashes = array();
       foreach ($package_symbols as $symbol) {
         $symbol_hash = idx($symbol_map, $symbol);
         if ($symbol_hash === null) {
           throw new Exception(
             pht(
               'Package specification for "%s" includes "%s", but that symbol '.
               'is not %s by any resource.',
               $package_name,
               $symbol,
               '@provided'));
         }
 
         $resource_name = $reverse_map[$symbol_hash];
         $resource_type = $resources->getResourceType($resource_name);
         if ($type === null) {
           $type = $resource_type;
         } else if ($type !== $resource_type) {
           throw new Exception(
             pht(
               'Package specification for "%s" includes resources of multiple '.
               'types (%s, %s). Each package may only contain one type of '.
               'resource.',
               $package_name,
               $type,
               $resource_type));
         }
 
         $hashes[] = $symbol.':'.$symbol_hash;
       }
 
       $hash = $this->newResourceHash(implode("\n", $hashes));
       $package_map[$package_name] = array(
         'hash' => $hash,
         'symbols' => $package_symbols,
       );
     }
 
     return $package_map;
   }
 
   private function mergeNameMaps(array $maps) {
     $result = array();
     $origin = array();
 
     foreach ($maps as $map) {
       list($map_name, $data) = $map;
       foreach ($data as $name => $hash) {
         if (empty($result[$name])) {
           $result[$name] = $hash;
           $origin[$name] = $map_name;
         } else {
           $old = $origin[$name];
           $new = $map_name;
           throw new Exception(
             pht(
               'Resource source defines two resources with the same name, '.
               '"%s". One is defined in the "%s" map; the other in the "%s" '.
               'map. Each resource must have a unique name.',
               $name,
               $old,
               $new));
         }
       }
     }
     return $result;
   }
 
   private function parseResourceSymbolList($list) {
     if (!$list) {
       return array();
     }
 
     // This is valid:
     //
     //   @requires x y
     //
     // But so is this:
     //
     //   @requires x
     //   @requires y
     //
     // Accept either form and produce a list of symbols.
 
     $list = (array)$list;
 
     // We can get `true` values if there was a bare `@requires` in the input.
     foreach ($list as $key => $item) {
       if ($item === true) {
         unset($list[$key]);
       }
     }
 
     $list = implode(' ', $list);
     $list = trim($list);
     $list = preg_split('/\s+/', $list);
     $list = array_filter($list);
 
     return $list;
   }
 
   private function newResourceHash($data) {
     // This HMAC key is a static, hard-coded value because we don't want the
     // hashes in the map to depend on database state: when two different
     // developers regenerate the map, they should end up with the same output.
 
     $hash = PhabricatorHash::digestHMACSHA256($data, 'celerity-resource-data');
 
     return substr($hash, 0, 8);
   }
 
 }
diff --git a/src/applications/celerity/CelerityResourceTransformer.php b/src/applications/celerity/CelerityResourceTransformer.php
index 6d86a8806a..8c49a83df7 100644
--- a/src/applications/celerity/CelerityResourceTransformer.php
+++ b/src/applications/celerity/CelerityResourceTransformer.php
@@ -1,268 +1,269 @@
 <?php
 
 final class CelerityResourceTransformer extends Phobject {
 
   private $minify;
   private $rawURIMap;
   private $celerityMap;
   private $translateURICallback;
   private $currentPath;
   private $postprocessorKey;
   private $variableMap;
 
   public function setPostprocessorKey($postprocessor_key) {
     $this->postprocessorKey = $postprocessor_key;
     return $this;
   }
 
   public function getPostprocessorKey() {
     return $this->postprocessorKey;
   }
 
   public function setTranslateURICallback($translate_uricallback) {
     $this->translateURICallback = $translate_uricallback;
     return $this;
   }
 
   public function setMinify($minify) {
     $this->minify = $minify;
     return $this;
   }
 
   public function setCelerityMap(CelerityResourceMap $celerity_map) {
     $this->celerityMap = $celerity_map;
     return $this;
   }
 
   public function setRawURIMap(array $raw_urimap) {
     $this->rawURIMap = $raw_urimap;
     return $this;
   }
 
   public function getRawURIMap() {
     return $this->rawURIMap;
   }
 
   /**
    * @phutil-external-symbol function jsShrink
    */
   public function transformResource($path, $data) {
     $type = self::getResourceType($path);
 
     switch ($type) {
       case 'css':
         $data = $this->replaceCSSPrintRules($path, $data);
         $data = $this->replaceCSSVariables($path, $data);
         $data = preg_replace_callback(
           '@url\s*\((\s*[\'"]?.*?)\)@s',
           nonempty(
             $this->translateURICallback,
             array($this, 'translateResourceURI')),
           $data);
         break;
     }
 
     if (!$this->minify) {
       return $data;
     }
 
     // Some resources won't survive minification (like d3.min.js), and are
     // marked so as not to be minified.
     if (strpos($data, '@'.'do-not-minify') !== false) {
       return $data;
     }
 
     switch ($type) {
       case 'css':
         // Remove comments.
         $data = preg_replace('@/\*.*?\*/@s', '', $data);
         // Remove whitespace around symbols.
         $data = preg_replace('@\s*([{}:;,])\s*@', '\1', $data);
         // Remove unnecessary semicolons.
         $data = preg_replace('@;}@', '}', $data);
         // Replace #rrggbb with #rgb when possible.
         $data = preg_replace(
           '@#([a-f0-9])\1([a-f0-9])\2([a-f0-9])\3@i',
           '#\1\2\3',
           $data);
         $data = trim($data);
         break;
       case 'js':
 
         // If `jsxmin` is available, use it. jsxmin is the Javelin minifier and
         // produces the smallest output, but is complicated to build.
         if (Filesystem::binaryExists('jsxmin')) {
           $future = new ExecFuture('jsxmin __DEV__:0');
           $future->write($data);
           list($err, $result) = $future->resolve();
           if (!$err) {
             $data = $result;
             break;
           }
         }
 
         // If `jsxmin` is not available, use `JsShrink`, which doesn't compress
         // quite as well but is always available.
         $root = dirname(phutil_get_library_root('phabricator'));
         require_once $root.'/externals/JsShrink/jsShrink.php';
         $data = jsShrink($data);
 
         break;
     }
 
     return $data;
   }
 
   public static function getResourceType($path) {
     return last(explode('.', $path));
   }
 
   public function translateResourceURI(array $matches) {
     $uri = trim($matches[1], "'\" \r\t\n");
     $tail = '';
 
     // If the resource URI has a query string or anchor, strip it off before
     // we go looking for the resource. We'll stitch it back on later. This
     // primarily affects FontAwesome.
 
     $parts = preg_split('/(?=[?#])/', $uri, 2);
     if (count($parts) == 2) {
       $uri = $parts[0];
       $tail = $parts[1];
     }
 
     $alternatives = array_unique(
       array(
         $uri,
         ltrim($uri, '/'),
       ));
 
     foreach ($alternatives as $alternative) {
       if ($this->rawURIMap !== null) {
         if (isset($this->rawURIMap[$alternative])) {
           $uri = $this->rawURIMap[$alternative];
           break;
         }
       }
 
       if ($this->celerityMap) {
         $resource_uri = $this->celerityMap->getURIForName($alternative);
         if ($resource_uri) {
           // Check if we can use a data URI for this resource. If not, just
           // use a normal Celerity URI.
           $data_uri = $this->generateDataURI($alternative);
           if ($data_uri) {
             $uri = $data_uri;
           } else {
             $uri = $resource_uri;
           }
           break;
         }
       }
     }
 
     return 'url('.$uri.$tail.')';
   }
 
   private function replaceCSSVariables($path, $data) {
     $this->currentPath = $path;
     return preg_replace_callback(
       '/{\$([^}]+)}/',
       array($this, 'replaceCSSVariable'),
       $data);
   }
 
   private function replaceCSSPrintRules($path, $data) {
     $this->currentPath = $path;
     return preg_replace_callback(
       '/!print\s+(.+?{.+?})/s',
       array($this, 'replaceCSSPrintRule'),
       $data);
   }
 
   public function getCSSVariableMap() {
     $postprocessor_key = $this->getPostprocessorKey();
     $postprocessor = CelerityPostprocessor::getPostprocessor(
       $postprocessor_key);
 
     if (!$postprocessor) {
       $postprocessor = CelerityPostprocessor::getPostprocessor(
         CelerityDefaultPostprocessor::POSTPROCESSOR_KEY);
     }
 
     return $postprocessor->getVariables();
   }
 
   public function replaceCSSVariable($matches) {
     if (!$this->variableMap) {
       $this->variableMap = $this->getCSSVariableMap();
     }
 
     $var_name = $matches[1];
     if (empty($this->variableMap[$var_name])) {
       $path = $this->currentPath;
       throw new Exception(
         pht(
           "CSS file '%s' has unknown variable '%s'.",
           $path,
           $var_name));
     }
 
     return $this->variableMap[$var_name];
   }
 
   public function replaceCSSPrintRule($matches) {
     $rule = $matches[1];
 
     $rules = array();
     $rules[] = '.printable '.$rule;
     $rules[] = "@media print {\n  ".str_replace("\n", "\n  ", $rule)."\n}\n";
 
     return implode("\n\n", $rules);
   }
 
 
   /**
    * Attempt to generate a data URI for a resource. We'll generate a data URI
    * if the resource is a valid resource of an appropriate type, and is
    * small enough. Otherwise, this method will return `null` and we'll end up
    * using a normal URI instead.
    *
-   * @param string  Resource name to attempt to generate a data URI for.
+   * @param string $resource_name Resource name to attempt to generate a data
+   *               URI for.
    * @return string|null Data URI, or null if we declined to generate one.
    */
   private function generateDataURI($resource_name) {
     $ext = last(explode('.', $resource_name));
     switch ($ext) {
       case 'png':
         $type = 'image/png';
         break;
       case 'gif':
         $type = 'image/gif';
         break;
       case 'jpg':
         $type = 'image/jpeg';
         break;
       default:
         return null;
     }
 
     // In IE8, 32KB is the maximum supported URI length.
     $maximum_data_size = (1024 * 32);
 
     $data = $this->celerityMap->getResourceDataForName($resource_name);
     if (strlen($data) >= $maximum_data_size) {
       // If the data is already too large on its own, just bail before
       // encoding it.
       return null;
     }
 
     $uri = 'data:'.$type.';base64,'.base64_encode($data);
     if (strlen($uri) >= $maximum_data_size) {
       return null;
     }
 
     return $uri;
   }
 
 }
diff --git a/src/applications/celerity/api.php b/src/applications/celerity/api.php
index 4340b57df0..98802b4598 100644
--- a/src/applications/celerity/api.php
+++ b/src/applications/celerity/api.php
@@ -1,52 +1,54 @@
 <?php
 
 /**
  * Include a CSS or JS static resource by name. This function records a
  * dependency for the current page, so when a response is generated it can be
  * included. You can call this method from any context, and it is recommended
  * you invoke it as close to the actual dependency as possible so that page
  * dependencies are minimized.
  *
  * For more information, see @{article:Adding New CSS and JS}.
  *
- * @param string Name of the celerity module to include. This is whatever you
- *               annotated as "@provides" in the file.
+ * @param string $symbol Name of the celerity module to include. This is
+ *               whatever you annotated as "@provides" in the file.
+ * @param string? $source_name
  * @return void
  */
 function require_celerity_resource($symbol, $source_name = 'phabricator') {
   $response = CelerityAPI::getStaticResourceResponse();
   $response->requireResource($symbol, $source_name);
 }
 
 
 /**
  * Generate a node ID which is guaranteed to be unique for the current page,
  * even across Ajax requests. You should use this method to generate IDs for
  * nodes which require a uniqueness guarantee.
  *
  * @return string A string appropriate for use as an 'id' attribute on a DOM
  *                node. It is guaranteed to be unique for the current page, even
  *                if the current request is a subsequent Ajax request.
  */
 function celerity_generate_unique_node_id() {
   static $uniq = 0;
   $response = CelerityAPI::getStaticResourceResponse();
   $block = $response->getMetadataBlock();
 
   return 'UQ'.$block.'_'.($uniq++);
 }
 
 
 /**
  * Get the versioned URI for a raw resource, like an image.
  *
- * @param   string  Path to the raw image.
+ * @param   string  $resource Path to the raw image.
+ * @param   string? $source
  * @return  string  Versioned path to the image, if one is available.
  */
 function celerity_get_resource_uri($resource, $source = 'phabricator') {
   $resource = ltrim($resource, '/');
 
   $map = CelerityResourceMap::getNamedInstance($source);
   $response = CelerityAPI::getStaticResourceResponse();
   return $response->getURI($map, $resource);
 }
diff --git a/src/applications/celerity/controller/CelerityResourceController.php b/src/applications/celerity/controller/CelerityResourceController.php
index 1908c4583a..26d8d05701 100644
--- a/src/applications/celerity/controller/CelerityResourceController.php
+++ b/src/applications/celerity/controller/CelerityResourceController.php
@@ -1,213 +1,213 @@
 <?php
 
 abstract class CelerityResourceController extends PhabricatorController {
 
   protected function buildResourceTransformer() {
     return null;
   }
 
   public function shouldRequireLogin() {
     return false;
   }
 
   public function shouldRequireEnabledUser() {
     return false;
   }
 
   public function shouldAllowPartialSessions() {
     return true;
   }
 
   public function shouldAllowLegallyNonCompliantUsers() {
     return true;
   }
 
   abstract public function getCelerityResourceMap();
 
   protected function serveResource(array $spec) {
     $path = $spec['path'];
     $hash = idx($spec, 'hash');
 
     // Sanity checking to keep this from exposing anything sensitive, since it
     // ultimately boils down to disk reads.
     if (preg_match('@(//|\.\.)@', $path)) {
       return new Aphront400Response();
     }
 
     $type = CelerityResourceTransformer::getResourceType($path);
     $type_map = self::getSupportedResourceTypes();
 
     if (empty($type_map[$type])) {
       throw new Exception(pht('Only static resources may be served.'));
     }
 
     $dev_mode = PhabricatorEnv::getEnvConfig('phabricator.developer-mode');
 
     $map = $this->getCelerityResourceMap();
     $expect_hash = $map->getHashForName($path);
 
     // Test if the URI hash is correct for our current resource map. If it
     // is not, refuse to cache this resource. This avoids poisoning caches
     // and CDNs if we're getting a request for a new resource to an old node
     // shortly after a push.
     $is_cacheable = ($hash === $expect_hash);
     $is_locally_cacheable = $this->isLocallyCacheableResourceType($type);
     if (AphrontRequest::getHTTPHeader('If-Modified-Since') && $is_cacheable) {
       // Return a "304 Not Modified". We don't care about the value of this
       // field since we never change what resource is served by a given URI.
       return $this->makeResponseCacheable(new Aphront304Response());
     }
 
     $cache = null;
     $cache_key = null;
     $data = null;
     if ($is_cacheable && $is_locally_cacheable && !$dev_mode) {
       $cache = PhabricatorCaches::getImmutableCache();
 
       $request_path = $this->getRequest()->getPath();
       $cache_key = $this->getCacheKey($request_path);
 
       $data = $cache->getKey($cache_key);
     }
 
     if ($data === null) {
       if ($map->isPackageResource($path)) {
         $resource_names = $map->getResourceNamesForPackageName($path);
         if (!$resource_names) {
           return new Aphront404Response();
         }
 
         try {
           $data = array();
           foreach ($resource_names as $resource_name) {
             $data[] = $map->getResourceDataForName($resource_name);
           }
           $data = implode("\n\n", $data);
         } catch (Exception $ex) {
           return new Aphront404Response();
         }
       } else {
         try {
           $data = $map->getResourceDataForName($path);
         } catch (Exception $ex) {
           return new Aphront404Response();
         }
       }
 
       $xformer = $this->buildResourceTransformer();
       if ($xformer) {
         $data = $xformer->transformResource($path, $data);
       }
 
       if ($cache && $cache_key !== null) {
         $cache->setKey($cache_key, $data);
       }
     }
 
     $response = id(new AphrontFileResponse())
       ->setMimeType($type_map[$type]);
 
     // The "Content-Security-Policy" header has no effect on the actual
     // resources, only on the main request. Disable it on the resource
     // responses to limit confusion.
     $response->setDisableContentSecurityPolicy(true);
 
     $range = AphrontRequest::getHTTPHeader('Range');
 
     if (phutil_nonempty_string($range)) {
       $response->setContentLength(strlen($data));
 
       list($range_begin, $range_end) = $response->parseHTTPRange($range);
 
       if ($range_begin !== null) {
         if ($range_end !== null) {
           $data = substr($data, $range_begin, ($range_end - $range_begin));
         } else {
           $data = substr($data, $range_begin);
         }
       }
 
       $response->setContentIterator(array($data));
     } else {
       $response
         ->setContent($data)
         ->setCompressResponse(true);
     }
 
 
     // NOTE: This is a piece of magic required to make WOFF fonts work in
     // Firefox and IE. Possibly we should generalize this more.
 
     $cross_origin_types = array(
       'woff' => true,
       'woff2' => true,
       'eot' => true,
     );
 
     if (isset($cross_origin_types[$type])) {
       // We could be more tailored here, but it's not currently trivial to
       // generate a comprehensive list of valid origins (an install may have
       // arbitrarily many Phame blogs, for example), and we lose nothing by
       // allowing access from anywhere.
       $response->addAllowOrigin('*');
     }
 
     if ($is_cacheable) {
       $response = $this->makeResponseCacheable($response);
     }
 
     return $response;
   }
 
   public static function getSupportedResourceTypes() {
     return array(
       'css' => 'text/css; charset=utf-8',
       'js'  => 'text/javascript; charset=utf-8',
       'png' => 'image/png',
       'svg' => 'image/svg+xml',
       'gif' => 'image/gif',
       'jpg' => 'image/jpeg',
       'swf' => 'application/x-shockwave-flash',
       'woff' => 'font/woff',
       'woff2' => 'font/woff2',
       'eot' => 'font/eot',
       'ttf' => 'font/ttf',
       'mp3' => 'audio/mpeg',
       'ico' => 'image/x-icon',
     );
   }
 
   private function makeResponseCacheable(AphrontResponse $response) {
     $response->setCacheDurationInSeconds(60 * 60 * 24 * 30);
     $response->setLastModified(time());
     $response->setCanCDN(true);
 
     return $response;
   }
 
 
   /**
    * Is it appropriate to cache the data for this resource type in the fast
    * immutable cache?
    *
    * Generally, text resources (which are small, and expensive to process)
    * are cached, while other types of resources (which are large, and cheap
    * to process) are not.
    *
-   * @param string  Resource type.
+   * @param string  $type Resource type.
    * @return bool   True to enable caching.
    */
   private function isLocallyCacheableResourceType($type) {
     $types = array(
       'js' => true,
       'css' => true,
     );
 
     return isset($types[$type]);
   }
 
   protected function getCacheKey($path) {
     return 'celerity:'.PhabricatorHash::digestToLength($path, 64);
   }
 
 }
diff --git a/src/applications/celerity/management/CelerityManagementMapWorkflow.php b/src/applications/celerity/management/CelerityManagementMapWorkflow.php
index e838de58f4..22fdebd686 100644
--- a/src/applications/celerity/management/CelerityManagementMapWorkflow.php
+++ b/src/applications/celerity/management/CelerityManagementMapWorkflow.php
@@ -1,56 +1,56 @@
 <?php
 
 final class CelerityManagementMapWorkflow
   extends CelerityManagementWorkflow {
 
   protected function didConstruct() {
     $this
       ->setName('map')
       ->setExamples('**map** [options]')
       ->setSynopsis(pht('Rebuild static resource maps.'))
       ->setArguments(
         array());
   }
 
   public function execute(PhutilArgumentParser $args) {
     $resources_map = CelerityPhysicalResources::getAll();
 
     $this->log(
       pht(
         'Rebuilding %d resource source(s).',
         phutil_count($resources_map)));
 
     foreach ($resources_map as $name => $resources) {
       $this->rebuildResources($resources);
     }
 
     $this->log(pht('Done.'));
 
     return 0;
   }
 
   /**
    * Rebuild the resource map for a resource source.
    *
-   * @param CelerityPhysicalResources Resource source to rebuild.
+   * @param $resources CelerityPhysicalResources Resource source to rebuild.
    * @return void
    */
   private function rebuildResources(CelerityPhysicalResources $resources) {
     $this->log(
       pht(
         'Rebuilding resource source "%s" (%s)...',
         $resources->getName(),
         get_class($resources)));
 
     id(new CelerityResourceMapGenerator($resources))
       ->setDebug(true)
       ->generate()
       ->write();
   }
 
   protected function log($message) {
     $console = PhutilConsole::getConsole();
     $console->writeErr("%s\n", $message);
   }
 
 }
diff --git a/src/applications/conduit/controller/PhabricatorConduitAPIController.php b/src/applications/conduit/controller/PhabricatorConduitAPIController.php
index 994f282f44..8eb19a2dc9 100644
--- a/src/applications/conduit/controller/PhabricatorConduitAPIController.php
+++ b/src/applications/conduit/controller/PhabricatorConduitAPIController.php
@@ -1,755 +1,756 @@
 <?php
 
 final class PhabricatorConduitAPIController
   extends PhabricatorConduitController {
 
   public function shouldRequireLogin() {
     return false;
   }
 
   public function handleRequest(AphrontRequest $request) {
     $method = $request->getURIData('method');
     $time_start = microtime(true);
 
     $api_request = null;
     $method_implementation = null;
 
     $log = new PhabricatorConduitMethodCallLog();
     $log->setMethod($method);
     $metadata = array();
 
     $multimeter = MultimeterControl::getInstance();
     if ($multimeter) {
       $multimeter->setEventContext('api.'.$method);
     }
 
     try {
 
       list($metadata, $params, $strictly_typed) = $this->decodeConduitParams(
         $request,
         $method);
 
       $call = new ConduitCall($method, $params, $strictly_typed);
       $method_implementation = $call->getMethodImplementation();
 
       $result = null;
 
       // TODO: The relationship between ConduitAPIRequest and ConduitCall is a
       // little odd here and could probably be improved. Specifically, the
       // APIRequest is a sub-object of the Call, which does not parallel the
       // role of AphrontRequest (which is an independent object).
       // In particular, the setUser() and getUser() existing independently on
       // the Call and APIRequest is very awkward.
 
       $api_request = $call->getAPIRequest();
 
       $allow_unguarded_writes = false;
       $auth_error = null;
       $conduit_username = '-';
       if ($call->shouldRequireAuthentication()) {
         $auth_error = $this->authenticateUser($api_request, $metadata, $method);
         // If we've explicitly authenticated the user here and either done
         // CSRF validation or are using a non-web authentication mechanism.
         $allow_unguarded_writes = true;
 
         if ($auth_error === null) {
           $conduit_user = $api_request->getUser();
           if ($conduit_user && $conduit_user->getPHID()) {
             $conduit_username = $conduit_user->getUsername();
           }
           $call->setUser($api_request->getUser());
         }
       }
 
       $access_log = PhabricatorAccessLog::getLog();
       if ($access_log) {
         $access_log->setData(
           array(
             'u' => $conduit_username,
             'm' => $method,
           ));
       }
 
       if ($call->shouldAllowUnguardedWrites()) {
         $allow_unguarded_writes = true;
       }
 
       if ($auth_error === null) {
         if ($allow_unguarded_writes) {
           $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
         }
 
         try {
           $result = $call->execute();
           $error_code = null;
           $error_info = null;
         } catch (ConduitException $ex) {
           $result = null;
           $error_code = $ex->getMessage();
           if ($ex->getErrorDescription()) {
             $error_info = $ex->getErrorDescription();
           } else {
             $error_info = $call->getErrorDescription($error_code);
           }
         }
         if ($allow_unguarded_writes) {
           unset($unguarded);
         }
       } else {
         list($error_code, $error_info) = $auth_error;
       }
     } catch (Exception $ex) {
       $result = null;
 
       if ($ex instanceof ConduitException) {
         $error_code = 'ERR-CONDUIT-CALL';
       } else {
         $error_code = 'ERR-CONDUIT-CORE';
 
         // See T13581. When a Conduit method raises an uncaught exception
         // other than a "ConduitException", log it.
         phlog($ex);
       }
 
       $error_info = $ex->getMessage();
     }
 
     $log
       ->setCallerPHID(
         isset($conduit_user)
           ? $conduit_user->getPHID()
           : null)
       ->setError((string)$error_code)
       ->setDuration(phutil_microseconds_since($time_start));
 
     if (!PhabricatorEnv::isReadOnly()) {
       $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       $log->save();
       unset($unguarded);
     }
 
     $response = id(new ConduitAPIResponse())
       ->setResult($result)
       ->setErrorCode($error_code)
       ->setErrorInfo($error_info);
 
     switch ($request->getStr('output')) {
       case 'human':
         return $this->buildHumanReadableResponse(
           $method,
           $api_request,
           $response->toDictionary(),
           $method_implementation);
       case 'json':
       default:
         $response = id(new AphrontJSONResponse())
           ->setAddJSONShield(false)
           ->setContent($response->toDictionary());
 
         $capabilities = $this->getConduitCapabilities();
         if ($capabilities) {
           $capabilities = implode(' ', $capabilities);
           $response->addHeader('X-Conduit-Capabilities', $capabilities);
         }
 
         return $response;
     }
   }
 
   /**
    * Authenticate the client making the request to a Phabricator user account.
    *
-   * @param   ConduitAPIRequest Request being executed.
-   * @param   dict              Request metadata.
+   * @param   ConduitAPIRequest $api_request Request being executed.
+   * @param   dict              $metadata Request metadata.
+   * @param   wild              $method
    * @return  null|pair         Null to indicate successful authentication, or
    *                            an error code and error message pair.
    */
   private function authenticateUser(
     ConduitAPIRequest $api_request,
     array $metadata,
     $method) {
 
     $request = $this->getRequest();
 
     if ($request->getUser()->getPHID()) {
       $request->validateCSRF();
       return $this->validateAuthenticatedUser(
         $api_request,
         $request->getUser());
     }
 
     $auth_type = idx($metadata, 'auth.type');
     if ($auth_type === ConduitClient::AUTH_ASYMMETRIC) {
       $host = idx($metadata, 'auth.host');
       if (!$host) {
         return array(
           'ERR-INVALID-AUTH',
           pht(
             'Request is missing required "%s" parameter.',
             'auth.host'),
         );
       }
 
       // TODO: Validate that we are the host!
 
       $raw_key = idx($metadata, 'auth.key');
       $public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($raw_key);
       $ssl_public_key = $public_key->toPKCS8();
 
       // First, verify the signature.
       try {
         $protocol_data = $metadata;
         ConduitClient::verifySignature(
           $method,
           $api_request->getAllParameters(),
           $protocol_data,
           $ssl_public_key);
       } catch (Exception $ex) {
         return array(
           'ERR-INVALID-AUTH',
           pht(
             'Signature verification failure. %s',
             $ex->getMessage()),
         );
       }
 
       // If the signature is valid, find the user or device which is
       // associated with this public key.
 
       $stored_key = id(new PhabricatorAuthSSHKeyQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withKeys(array($public_key))
         ->withIsActive(true)
         ->executeOne();
       if (!$stored_key) {
         $key_summary = id(new PhutilUTF8StringTruncator())
           ->setMaximumBytes(64)
           ->truncateString($raw_key);
         return array(
           'ERR-INVALID-AUTH',
           pht(
             'No user or device is associated with the public key "%s".',
             $key_summary),
         );
       }
 
       $object = $stored_key->getObject();
 
       if ($object instanceof PhabricatorUser) {
         $user = $object;
       } else {
         if ($object->isDisabled()) {
           return array(
             'ERR-INVALID-AUTH',
             pht(
               'The key which signed this request is associated with a '.
               'disabled device ("%s").',
               $object->getName()),
           );
         }
 
         if (!$stored_key->getIsTrusted()) {
           return array(
             'ERR-INVALID-AUTH',
             pht(
               'The key which signed this request is not trusted. Only '.
               'trusted keys can be used to sign API calls.'),
           );
         }
 
         if (!PhabricatorEnv::isClusterRemoteAddress()) {
           return array(
             'ERR-INVALID-AUTH',
             pht(
               'This request originates from outside of the cluster address '.
               'range. Requests signed with trusted device keys must '.
               'originate from within the cluster.'),
           );
         }
 
         $user = PhabricatorUser::getOmnipotentUser();
 
         // Flag this as an intracluster request.
         $api_request->setIsClusterRequest(true);
       }
 
       return $this->validateAuthenticatedUser(
         $api_request,
         $user);
     } else if ($auth_type === null) {
       // No specified authentication type, continue with other authentication
       // methods below.
     } else {
       return array(
         'ERR-INVALID-AUTH',
         pht(
           'Provided "%s" ("%s") is not recognized.',
           'auth.type',
           $auth_type),
       );
     }
 
     $token_string = idx($metadata, 'token', '');
     if (strlen($token_string)) {
 
       if (strlen($token_string) != 32) {
         return array(
           'ERR-INVALID-AUTH',
           pht(
             'API token "%s" has the wrong length. API tokens should be '.
             '32 characters long.',
             $token_string),
         );
       }
 
       $type = head(explode('-', $token_string));
       $valid_types = PhabricatorConduitToken::getAllTokenTypes();
       $valid_types = array_fuse($valid_types);
       if (empty($valid_types[$type])) {
         return array(
           'ERR-INVALID-AUTH',
           pht(
             'API token "%s" has the wrong format. API tokens should be '.
             '32 characters long and begin with one of these prefixes: %s.',
             $token_string,
             implode(', ', $valid_types)),
           );
       }
 
       $token = id(new PhabricatorConduitTokenQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withTokens(array($token_string))
         ->withExpired(false)
         ->executeOne();
       if (!$token) {
         $token = id(new PhabricatorConduitTokenQuery())
           ->setViewer(PhabricatorUser::getOmnipotentUser())
           ->withTokens(array($token_string))
           ->withExpired(true)
           ->executeOne();
         if ($token) {
           return array(
             'ERR-INVALID-AUTH',
             pht(
               'API token "%s" was previously valid, but has expired.',
               $token_string),
           );
         } else {
           return array(
             'ERR-INVALID-AUTH',
             pht(
               'API token "%s" is not valid.',
               $token_string),
           );
         }
       }
 
       // If this is a "cli-" token, it expires shortly after it is generated
       // by default. Once it is actually used, we extend its lifetime and make
       // it permanent. This allows stray tokens to get cleaned up automatically
       // if they aren't being used.
       if ($token->getTokenType() == PhabricatorConduitToken::TYPE_COMMANDLINE) {
         if ($token->getExpires()) {
           $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
             $token->setExpires(null);
             $token->save();
           unset($unguarded);
         }
       }
 
       // If this is a "clr-" token, Phabricator must be configured in cluster
       // mode and the remote address must be a cluster node.
       if ($token->getTokenType() == PhabricatorConduitToken::TYPE_CLUSTER) {
         if (!PhabricatorEnv::isClusterRemoteAddress()) {
           return array(
             'ERR-INVALID-AUTH',
             pht(
               'This request originates from outside of the cluster address '.
               'range. Requests signed with cluster API tokens must '.
               'originate from within the cluster.'),
           );
         }
 
         // Flag this as an intracluster request.
         $api_request->setIsClusterRequest(true);
       }
 
       $user = $token->getObject();
       if (!($user instanceof PhabricatorUser)) {
         return array(
           'ERR-INVALID-AUTH',
           pht('API token is not associated with a valid user.'),
         );
       }
 
       return $this->validateAuthenticatedUser(
         $api_request,
         $user);
     }
 
     $access_token = idx($metadata, 'access_token');
     if ($access_token) {
       $token = id(new PhabricatorOAuthServerAccessToken())
         ->loadOneWhere('token = %s', $access_token);
       if (!$token) {
         return array(
           'ERR-INVALID-AUTH',
           pht('Access token does not exist.'),
         );
       }
 
       $oauth_server = new PhabricatorOAuthServer();
       $authorization = $oauth_server->authorizeToken($token);
       if (!$authorization) {
         return array(
           'ERR-INVALID-AUTH',
           pht('Access token is invalid or expired.'),
         );
       }
 
       $user = id(new PhabricatorPeopleQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withPHIDs(array($token->getUserPHID()))
         ->executeOne();
       if (!$user) {
         return array(
           'ERR-INVALID-AUTH',
           pht('Access token is for invalid user.'),
         );
       }
 
       $ok = $this->authorizeOAuthMethodAccess($authorization, $method);
       if (!$ok) {
         return array(
           'ERR-OAUTH-ACCESS',
           pht('You do not have authorization to call this method.'),
         );
       }
 
       $api_request->setOAuthToken($token);
 
       return $this->validateAuthenticatedUser(
         $api_request,
         $user);
     }
 
 
     // For intracluster requests, use a public user if no authentication
     // information is provided. We could do this safely for any request,
     // but making the API fully public means there's no way to disable badly
     // behaved clients.
     if (PhabricatorEnv::isClusterRemoteAddress()) {
       if (PhabricatorEnv::getEnvConfig('policy.allow-public')) {
         $api_request->setIsClusterRequest(true);
 
         $user = new PhabricatorUser();
         return $this->validateAuthenticatedUser(
           $api_request,
           $user);
       }
     }
 
 
     // Handle sessionless auth.
     // TODO: This is super messy.
     // TODO: Remove this in favor of token-based auth.
 
     if (isset($metadata['authUser'])) {
       $user = id(new PhabricatorUser())->loadOneWhere(
         'userName = %s',
         $metadata['authUser']);
       if (!$user) {
         return array(
           'ERR-INVALID-AUTH',
           pht('Authentication is invalid.'),
         );
       }
       $token = idx($metadata, 'authToken');
       $signature = idx($metadata, 'authSignature');
       $certificate = $user->getConduitCertificate();
       $hash = sha1($token.$certificate);
       if (!phutil_hashes_are_identical($hash, $signature)) {
         return array(
           'ERR-INVALID-AUTH',
           pht('Authentication is invalid.'),
         );
       }
       return $this->validateAuthenticatedUser(
         $api_request,
         $user);
     }
 
     // Handle session-based auth.
     // TODO: Remove this in favor of token-based auth.
 
     $session_key = idx($metadata, 'sessionKey');
     if (!$session_key) {
       return array(
         'ERR-INVALID-SESSION',
         pht('Session key is not present.'),
       );
     }
 
     $user = id(new PhabricatorAuthSessionEngine())
       ->loadUserForSession(PhabricatorAuthSession::TYPE_CONDUIT, $session_key);
 
     if (!$user) {
       return array(
         'ERR-INVALID-SESSION',
         pht('Session key is invalid.'),
       );
     }
 
     return $this->validateAuthenticatedUser(
       $api_request,
       $user);
   }
 
   private function validateAuthenticatedUser(
     ConduitAPIRequest $request,
     PhabricatorUser $user) {
 
     if (!$user->canEstablishAPISessions()) {
       return array(
         'ERR-INVALID-AUTH',
         pht('User account is not permitted to use the API.'),
       );
     }
 
     $request->setUser($user);
 
     id(new PhabricatorAuthSessionEngine())
       ->willServeRequestForUser($user);
 
     return null;
   }
 
   private function buildHumanReadableResponse(
     $method,
     ConduitAPIRequest $request = null,
     $result = null,
     ConduitAPIMethod $method_implementation = null) {
 
     $param_rows = array();
     $param_rows[] = array('Method', $this->renderAPIValue($method));
     if ($request) {
       foreach ($request->getAllParameters() as $key => $value) {
         $param_rows[] = array(
           $key,
           $this->renderAPIValue($value),
         );
       }
     }
 
     $param_table = new AphrontTableView($param_rows);
     $param_table->setColumnClasses(
       array(
         'header',
         'wide',
       ));
 
     $result_rows = array();
     foreach ($result as $key => $value) {
       $result_rows[] = array(
         $key,
         $this->renderAPIValue($value),
       );
     }
 
     $result_table = new AphrontTableView($result_rows);
     $result_table->setColumnClasses(
       array(
         'header',
         'wide',
       ));
 
     $param_panel = id(new PHUIObjectBoxView())
       ->setHeaderText(pht('Method Parameters'))
       ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
       ->setTable($param_table);
 
     $result_panel = id(new PHUIObjectBoxView())
       ->setHeaderText(pht('Method Result'))
       ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
       ->setTable($result_table);
 
     $method_uri = $this->getApplicationURI('method/'.$method.'/');
 
     $crumbs = $this->buildApplicationCrumbs()
       ->addTextCrumb($method, $method_uri)
       ->addTextCrumb(pht('Call'))
       ->setBorder(true);
 
     $example_panel = null;
     if ($request && $method_implementation) {
       $params = $request->getAllParameters();
       $example_panel = $this->renderExampleBox(
         $method_implementation,
         $params);
     }
 
     $title = pht('Method Call Result');
     $header = id(new PHUIHeaderView())
       ->setHeader($title)
       ->setHeaderIcon('fa-exchange');
 
     $view = id(new PHUITwoColumnView())
       ->setHeader($header)
       ->setFooter(array(
         $param_panel,
         $result_panel,
         $example_panel,
       ));
 
     $title = pht('Method Call Result');
 
     return $this->newPage()
       ->setTitle($title)
       ->setCrumbs($crumbs)
       ->appendChild($view);
 
   }
 
   private function renderAPIValue($value) {
     $json = new PhutilJSON();
     if (is_array($value)) {
       $value = $json->encodeFormatted($value);
     }
 
     $value = phutil_tag(
       'pre',
       array('style' => 'white-space: pre-wrap;'),
       $value);
 
     return $value;
   }
 
   private function decodeConduitParams(
     AphrontRequest $request,
     $method) {
 
     $content_type = $request->getHTTPHeader('Content-Type');
 
     if ($content_type == 'application/json') {
       throw new Exception(
         pht('Use form-encoded data to submit parameters to Conduit endpoints. '.
             'Sending a JSON-encoded body and setting \'Content-Type\': '.
             '\'application/json\' is not currently supported.'));
     }
 
     // Look for parameters from the Conduit API Console, which are encoded
     // as HTTP POST parameters in an array, e.g.:
     //
     //   params[name]=value&params[name2]=value2
     //
     // The fields are individually JSON encoded, since we require users to
     // enter JSON so that we avoid type ambiguity.
 
     $params = $request->getArr('params', null);
     if ($params !== null) {
       foreach ($params as $key => $value) {
         if ($value == '') {
           // Interpret empty string null (e.g., the user didn't type anything
           // into the box).
           $value = 'null';
         }
         $decoded_value = json_decode($value, true);
         if ($decoded_value === null && strtolower($value) != 'null') {
           // When json_decode() fails, it returns null. This almost certainly
           // indicates that a user was using the web UI and didn't put quotes
           // around a string value. We can either do what we think they meant
           // (treat it as a string) or fail. For now, err on the side of
           // caution and fail. In the future, if we make the Conduit API
           // actually do type checking, it might be reasonable to treat it as
           // a string if the parameter type is string.
           throw new Exception(
             pht(
               "The value for parameter '%s' is not valid JSON. All ".
               "parameters must be encoded as JSON values, including strings ".
               "(which means you need to surround them in double quotes). ".
               "Check your syntax. Value was: %s.",
               $key,
               $value));
         }
         $params[$key] = $decoded_value;
       }
 
       $metadata = idx($params, '__conduit__', array());
       unset($params['__conduit__']);
 
       return array($metadata, $params, true);
     }
 
     // Otherwise, look for a single parameter called 'params' which has the
     // entire param dictionary JSON encoded.
     $params_json = $request->getStr('params');
     if (phutil_nonempty_string($params_json)) {
       $params = null;
       try {
         $params = phutil_json_decode($params_json);
       } catch (PhutilJSONParserException $ex) {
         throw new PhutilProxyException(
           pht(
             "Invalid parameter information was passed to method '%s'.",
             $method),
           $ex);
       }
 
       $metadata = idx($params, '__conduit__', array());
       unset($params['__conduit__']);
 
       return array($metadata, $params, true);
     }
 
     // If we do not have `params`, assume this is a simple HTTP request with
     // HTTP key-value pairs.
     $params = array();
     $metadata = array();
     foreach ($request->getPassthroughRequestData() as $key => $value) {
       $meta_key = ConduitAPIMethod::getParameterMetadataKey($key);
       if ($meta_key !== null) {
         $metadata[$meta_key] = $value;
       } else {
         $params[$key] = $value;
       }
     }
 
     return array($metadata, $params, false);
   }
 
   private function authorizeOAuthMethodAccess(
     PhabricatorOAuthClientAuthorization $authorization,
     $method_name) {
 
     $method = ConduitAPIMethod::getConduitMethod($method_name);
     if (!$method) {
       return false;
     }
 
     $required_scope = $method->getRequiredScope();
     switch ($required_scope) {
       case ConduitAPIMethod::SCOPE_ALWAYS:
         return true;
       case ConduitAPIMethod::SCOPE_NEVER:
         return false;
     }
 
     $authorization_scope = $authorization->getScope();
     if (!empty($authorization_scope[$required_scope])) {
       return true;
     }
 
     return false;
   }
 
   private function getConduitCapabilities() {
     $capabilities = array();
 
     if (AphrontRequestStream::supportsGzip()) {
       $capabilities[] = 'gzip';
     }
 
     return $capabilities;
   }
 
 }
diff --git a/src/applications/conduit/protocol/exception/ConduitException.php b/src/applications/conduit/protocol/exception/ConduitException.php
index c30931de04..d604de65ef 100644
--- a/src/applications/conduit/protocol/exception/ConduitException.php
+++ b/src/applications/conduit/protocol/exception/ConduitException.php
@@ -1,32 +1,32 @@
 <?php
 
 /**
  * @concrete-extensible
  */
 class ConduitException extends Exception {
 
   private $errorDescription;
 
   /**
    * Set a detailed error description. If omitted, the generic error description
    * will be used instead. This is useful to provide specific information about
    * an exception (e.g., which values were wrong in an invalid request).
    *
-   * @param string Detailed error description.
+   * @param string $error_description Detailed error description.
    * @return this
    */
   final public function setErrorDescription($error_description) {
     $this->errorDescription = $error_description;
     return $this;
   }
 
   /**
    * Get a detailed error description, if available.
    *
    * @return string|null Error description, if one is available.
    */
   final public function getErrorDescription() {
     return $this->errorDescription;
   }
 
 }
diff --git a/src/applications/config/custom/PhabricatorCustomLogoConfigType.php b/src/applications/config/custom/PhabricatorCustomLogoConfigType.php
index 6811f618cf..6443864bc9 100644
--- a/src/applications/config/custom/PhabricatorCustomLogoConfigType.php
+++ b/src/applications/config/custom/PhabricatorCustomLogoConfigType.php
@@ -1,157 +1,157 @@
 <?php
 
 final class PhabricatorCustomLogoConfigType
   extends PhabricatorConfigOptionType {
 
   public static function getLogoImagePHID() {
     $logo = PhabricatorEnv::getEnvConfig('ui.logo');
     return idx($logo, 'logoImagePHID');
   }
 
   public static function getLogoWordmark() {
     $logo = PhabricatorEnv::getEnvConfig('ui.logo');
     return idx($logo, 'wordmarkText');
   }
 
   /**
    * Return the full URI of the Phorge logo
-   * @param PhabricatorUser Current viewer
+   * @param PhabricatorUser $viewer Current viewer
    * @return string Full URI of the Phorge logo
    */
   public static function getLogoURI(PhabricatorUser $viewer) {
     $logo_uri = null;
 
     $custom_header = self::getLogoImagePHID();
     if ($custom_header) {
       $cache = PhabricatorCaches::getImmutableCache();
       $cache_key_logo = 'ui.custom-header.logo-phid.v3.'.$custom_header;
       $logo_uri = $cache->getKey($cache_key_logo);
 
       if (!$logo_uri) {
         // NOTE: If the file policy has been changed to be restrictive, we'll
         // miss here and just show the default logo. The cache will fill later
         // when someone who can see the file loads the page. This might be a
         // little spooky, see T11982.
         $files = id(new PhabricatorFileQuery())
           ->setViewer($viewer)
           ->withPHIDs(array($custom_header))
           ->execute();
         $file = head($files);
         if ($file) {
           $logo_uri = $file->getViewURI();
           $cache->setKey($cache_key_logo, $logo_uri);
         }
       }
     }
 
     if (!$logo_uri) {
       $logo_uri =
         celerity_get_resource_uri('/rsrc/image/logo/project-logo.png');
     }
 
     return $logo_uri;
   }
 
   public function validateOption(PhabricatorConfigOption $option, $value) {
     if (!is_array($value)) {
       throw new Exception(
         pht(
           'Logo configuration is not valid: value must be a dictionary.'));
     }
 
     PhutilTypeSpec::checkMap(
       $value,
       array(
         'logoImagePHID' => 'optional string|null',
         'wordmarkText' => 'optional string|null',
       ));
   }
 
   public function readRequest(
     PhabricatorConfigOption $option,
     AphrontRequest $request) {
 
     $viewer = $request->getViewer();
     $view_policy = PhabricatorPolicies::POLICY_PUBLIC;
 
     if ($request->getBool('removeLogo')) {
       $logo_image_phid = null;
     } else if ($request->getFileExists('logoImage')) {
       $logo_image = PhabricatorFile::newFromPHPUpload(
         idx($_FILES, 'logoImage'),
         array(
           'name' => 'logo',
           'authorPHID' => $viewer->getPHID(),
           'viewPolicy' => $view_policy,
           'canCDN' => true,
           'isExplicitUpload' => true,
         ));
       $logo_image_phid = $logo_image->getPHID();
     } else {
       $logo_image_phid = self::getLogoImagePHID();
     }
 
     $wordmark_text = $request->getStr('wordmarkText');
 
     $value = array(
       'logoImagePHID' => $logo_image_phid,
       'wordmarkText' => $wordmark_text,
     );
 
     $errors = array();
     $e_value = null;
 
     try {
       $this->validateOption($option, $value);
     } catch (Exception $ex) {
       $e_value = pht('Invalid');
       $errors[] = $ex->getMessage();
       $value = array();
     }
 
     return array($e_value, $errors, $value, phutil_json_encode($value));
   }
 
   public function renderControls(
     PhabricatorConfigOption $option,
     $display_value,
     $e_value) {
 
     try {
       $value = phutil_json_decode($display_value);
     } catch (Exception $ex) {
       $value = array();
     }
 
     $logo_image_phid = idx($value, 'logoImagePHID');
     $wordmark_text = idx($value, 'wordmarkText');
 
     $controls = array();
 
     // TODO: This should be a PHUIFormFileControl, but that currently only
     // works in "workflow" forms. It isn't trivial to convert this form into
     // a workflow form, nor is it trivial to make the newer control work
     // in non-workflow forms.
     $controls[] = id(new AphrontFormFileControl())
       ->setName('logoImage')
       ->setLabel(pht('Logo Image'));
 
     if ($logo_image_phid) {
       $controls[] = id(new AphrontFormCheckboxControl())
         ->addCheckbox(
           'removeLogo',
           1,
           pht('Remove Custom Logo'));
     }
 
     $controls[] = id(new AphrontFormTextControl())
       ->setName('wordmarkText')
       ->setLabel(pht('Wordmark'))
       ->setPlaceholder(PlatformSymbols::getPlatformServerName())
       ->setValue($wordmark_text);
 
     return $controls;
   }
 
 
 }
diff --git a/src/applications/config/issue/PhabricatorSetupIssue.php b/src/applications/config/issue/PhabricatorSetupIssue.php
index cadedfc7da..3b6503113d 100644
--- a/src/applications/config/issue/PhabricatorSetupIssue.php
+++ b/src/applications/config/issue/PhabricatorSetupIssue.php
@@ -1,231 +1,231 @@
 <?php
 
 final class PhabricatorSetupIssue extends Phobject {
 
   private $issueKey;
   private $name;
   private $message;
   private $isFatal;
   private $summary;
   private $shortName;
   private $group;
   private $databaseRef;
 
   private $isIgnored = false;
   private $phpExtensions = array();
   private $phabricatorConfig = array();
   private $relatedPhabricatorConfig = array();
   private $phpConfig = array();
   private $commands = array();
   private $mysqlConfig = array();
   private $originalPHPConfigValues = array();
   private $links;
 
   public static function newDatabaseConnectionIssue(
     Exception $ex,
     $is_fatal) {
 
     $message = pht(
       "Unable to connect to MySQL!\n\n".
       "%s\n\n".
       "Make sure databases connection information and MySQL are ".
       "correctly configured.",
       $ex->getMessage());
 
     $issue = id(new self())
       ->setIssueKey('mysql.connect')
       ->setName(pht('Can Not Connect to MySQL'))
       ->setMessage($message)
       ->setIsFatal($is_fatal)
       ->addRelatedPhabricatorConfig('mysql.host')
       ->addRelatedPhabricatorConfig('mysql.port')
       ->addRelatedPhabricatorConfig('mysql.user')
       ->addRelatedPhabricatorConfig('mysql.pass');
 
     if (PhabricatorEnv::getEnvConfig('cluster.databases')) {
       $issue->addRelatedPhabricatorConfig('cluster.databases');
     }
 
     return $issue;
   }
 
   public function addCommand($command) {
     $this->commands[] = $command;
     return $this;
   }
 
   public function getCommands() {
     return $this->commands;
   }
 
   public function setShortName($short_name) {
     $this->shortName = $short_name;
     return $this;
   }
 
   public function getShortName() {
     if ($this->shortName === null) {
       return $this->getName();
     }
     return $this->shortName;
   }
 
   public function setDatabaseRef(PhabricatorDatabaseRef $database_ref) {
     $this->databaseRef = $database_ref;
     return $this;
   }
 
   public function getDatabaseRef() {
     return $this->databaseRef;
   }
 
   public function setGroup($group) {
     $this->group = $group;
     return $this;
   }
 
   public function getGroup() {
     if ($this->group) {
       return $this->group;
     } else {
       return PhabricatorSetupCheck::GROUP_OTHER;
     }
   }
 
   public function setName($name) {
     $this->name = $name;
     return $this;
   }
 
   public function getName() {
     return $this->name;
   }
 
   public function setSummary($summary) {
     $this->summary = $summary;
     return $this;
   }
 
   public function getSummary() {
     if ($this->summary === null) {
       return $this->getMessage();
     }
     return $this->summary;
   }
 
   public function setIssueKey($issue_key) {
     $this->issueKey = $issue_key;
     return $this;
   }
 
   public function getIssueKey() {
     return $this->issueKey;
   }
 
   public function setIsFatal($is_fatal) {
     $this->isFatal = $is_fatal;
     return $this;
   }
 
   public function getIsFatal() {
     return $this->isFatal;
   }
 
   public function addPHPConfig($php_config) {
     $this->phpConfig[] = $php_config;
     return $this;
   }
 
   /**
    * Set an explicit value to display when showing the user PHP configuration
    * values.
    *
    * If Phabricator has changed a value by the time a config issue is raised,
    * you can provide the original value here so the UI makes sense. For example,
    * we alter `memory_limit` during startup, so if the original value is not
    * provided it will look like it is always set to `-1`.
    *
-   * @param string PHP configuration option to provide a value for.
-   * @param string Explicit value to show in the UI.
+   * @param string $php_config PHP configuration option to provide a value for.
+   * @param string $value Explicit value to show in the UI.
    * @return this
    */
   public function addPHPConfigOriginalValue($php_config, $value) {
     $this->originalPHPConfigValues[$php_config] = $value;
     return $this;
   }
 
   public function getPHPConfigOriginalValue($php_config, $default = null) {
     return idx($this->originalPHPConfigValues, $php_config, $default);
   }
 
   public function getPHPConfig() {
     return $this->phpConfig;
   }
 
   public function addMySQLConfig($mysql_config) {
     $this->mysqlConfig[] = $mysql_config;
     return $this;
   }
 
   public function getMySQLConfig() {
     return $this->mysqlConfig;
   }
 
   public function addPhabricatorConfig($phabricator_config) {
     $this->phabricatorConfig[] = $phabricator_config;
     return $this;
   }
 
   public function getPhabricatorConfig() {
     return $this->phabricatorConfig;
   }
 
   public function addRelatedPhabricatorConfig($phabricator_config) {
     $this->relatedPhabricatorConfig[] = $phabricator_config;
     return $this;
   }
 
   public function getRelatedPhabricatorConfig() {
     return $this->relatedPhabricatorConfig;
   }
 
   public function addPHPExtension($php_extension) {
     $this->phpExtensions[] = $php_extension;
     return $this;
   }
 
   public function getPHPExtensions() {
     return $this->phpExtensions;
   }
 
   public function setMessage($message) {
     $this->message = $message;
     return $this;
   }
 
   public function getMessage() {
     return $this->message;
   }
 
   public function setIsIgnored($is_ignored) {
     $this->isIgnored = $is_ignored;
     return $this;
   }
 
   public function getIsIgnored() {
     return $this->isIgnored;
   }
 
   public function addLink($href, $name) {
     $this->links[] = array(
       'href' => $href,
       'name' => $name,
     );
     return $this;
   }
 
   public function getLinks() {
     return $this->links;
   }
 
 }
diff --git a/src/applications/config/json/PhabricatorConfigJSON.php b/src/applications/config/json/PhabricatorConfigJSON.php
index 0433e4a1dc..bbd84fc2bc 100644
--- a/src/applications/config/json/PhabricatorConfigJSON.php
+++ b/src/applications/config/json/PhabricatorConfigJSON.php
@@ -1,49 +1,50 @@
 <?php
 
 final class PhabricatorConfigJSON extends Phobject {
   /**
    * Properly format a JSON value.
    *
-   * @param wild Any value, but should be a raw value, not a string of JSON.
+   * @param wild $value Any value, but should be a raw value, not a string of
+   *   JSON.
    * @return string
    */
   public static function prettyPrintJSON($value) {
     // If the value is an array with keys "0, 1, 2, ..." then we want to
     // show it as a list.
     // If the value is an array with other keys, we want to show it as an
     // object.
     // Otherwise, just use the default encoder.
 
     $type = null;
     if (is_array($value)) {
       $list_keys = range(0, count($value) - 1);
       $actual_keys = array_keys($value);
 
       if ($actual_keys === $list_keys) {
         $type = 'list';
       } else {
         $type = 'object';
       }
     }
 
     switch ($type) {
       case 'list':
         $result = id(new PhutilJSON())->encodeAsList($value);
         break;
       case 'object':
         $result = id(new PhutilJSON())->encodeFormatted($value);
         break;
       default:
         $result = json_encode($value);
         break;
     }
 
     // For readability, unescape forward slashes. These are normally escaped
     // to prevent the string "</script>" from appearing in a JSON literal,
     // but it's irrelevant here and makes reading paths more difficult than
     // necessary.
     $result = str_replace('\\/', '/', $result);
     return $result;
 
   }
 }
diff --git a/src/applications/config/option/PhabricatorApplicationConfigOptions.php b/src/applications/config/option/PhabricatorApplicationConfigOptions.php
index 49cd9eb17e..e8aea20ba7 100644
--- a/src/applications/config/option/PhabricatorApplicationConfigOptions.php
+++ b/src/applications/config/option/PhabricatorApplicationConfigOptions.php
@@ -1,157 +1,157 @@
 <?php
 
 abstract class PhabricatorApplicationConfigOptions extends Phobject {
 
   abstract public function getName();
   abstract public function getDescription();
   abstract public function getGroup();
   abstract public function getOptions();
 
   public function getIcon() {
     return 'fa-sliders';
   }
 
   public function validateOption(PhabricatorConfigOption $option, $value) {
     if ($value === $option->getDefault()) {
       return;
     }
 
     if ($value === null) {
       return;
     }
 
     $type = $option->newOptionType();
     if ($type) {
       try {
         $type->validateStoredValue($option, $value);
         $this->didValidateOption($option, $value);
       } catch (PhabricatorConfigValidationException $ex) {
         throw $ex;
       } catch (Exception $ex) {
         // If custom validators threw exceptions other than validation
         // exceptions, convert them to validation exceptions so we repair the
         // configuration and raise an error.
         throw new PhabricatorConfigValidationException($ex->getMessage());
       }
 
       return;
     }
 
     if ($option->isCustomType()) {
       try {
         return $option->getCustomObject()->validateOption($option, $value);
       } catch (Exception $ex) {
         throw new PhabricatorConfigValidationException($ex->getMessage());
       }
     } else {
       throw new Exception(
         pht(
           'Unknown configuration option type "%s".',
           $option->getType()));
     }
 
     $this->didValidateOption($option, $value);
   }
 
   protected function didValidateOption(
     PhabricatorConfigOption $option,
     $value) {
     // Hook for subclasses to do complex validation.
     return;
   }
 
   /**
    * Hook to render additional hints based on, e.g., the viewing user, request,
    * or other context. For example, this is used to show workspace IDs when
    * configuring `asana.workspace-id`.
    *
-   * @param   PhabricatorConfigOption   Option being rendered.
-   * @param   AphrontRequest            Active request.
+   * @param   PhabricatorConfigOption   $option Option being rendered.
+   * @param   AphrontRequest            $request Active request.
    * @return  wild                      Additional contextual description
    *                                    information.
    */
   public function renderContextualDescription(
     PhabricatorConfigOption $option,
     AphrontRequest $request) {
     return null;
   }
 
   public function getKey() {
     $class = get_class($this);
     $matches = null;
     if (preg_match('/^Phabricator(.*)ConfigOptions$/', $class, $matches)) {
       return strtolower($matches[1]);
     }
     return strtolower(get_class($this));
   }
 
   final protected function newOption($key, $type, $default) {
     return id(new PhabricatorConfigOption())
       ->setKey($key)
       ->setType($type)
       ->setDefault($default)
       ->setGroup($this);
   }
 
   final public static function loadAll($external_only = false) {
     $symbols = id(new PhutilSymbolLoader())
       ->setAncestorClass(__CLASS__)
       ->setConcreteOnly(true)
       ->selectAndLoadSymbols();
 
     $groups = array();
     foreach ($symbols as $symbol) {
       if ($external_only && $symbol['library'] == 'phabricator') {
         continue;
       }
 
       $obj = newv($symbol['name'], array());
       $key = $obj->getKey();
       if (isset($groups[$key])) {
         $pclass = get_class($groups[$key]);
         $nclass = $symbol['name'];
 
         throw new Exception(
           pht(
             "Multiple %s subclasses have the same key ('%s'): %s, %s.",
             __CLASS__,
             $key,
             $pclass,
             $nclass));
       }
       $groups[$key] = $obj;
     }
 
     return $groups;
   }
 
   final public static function loadAllOptions($external_only = false) {
     $groups = self::loadAll($external_only);
 
     $options = array();
     foreach ($groups as $group) {
       foreach ($group->getOptions() as $option) {
         $key = $option->getKey();
         if (isset($options[$key])) {
           throw new Exception(
             pht(
               "Multiple %s subclasses contain an option named '%s'!",
               __CLASS__,
               $key));
         }
         $options[$key] = $option;
       }
     }
 
     return $options;
   }
 
   /**
    * Deformat a HEREDOC for use in remarkup by converting line breaks to
    * spaces.
    */
   final protected function deformat($string) {
     return preg_replace('/(?<=\S)\n(?=\S)/', ' ', $string);
   }
 
 }
diff --git a/src/applications/dashboard/engine/PhabricatorDashboardPanelRenderingEngine.php b/src/applications/dashboard/engine/PhabricatorDashboardPanelRenderingEngine.php
index 3b9c3ad192..655d3be51b 100644
--- a/src/applications/dashboard/engine/PhabricatorDashboardPanelRenderingEngine.php
+++ b/src/applications/dashboard/engine/PhabricatorDashboardPanelRenderingEngine.php
@@ -1,468 +1,468 @@
 <?php
 
 final class PhabricatorDashboardPanelRenderingEngine extends Phobject {
 
   const HEADER_MODE_NORMAL = 'normal';
   const HEADER_MODE_NONE   = 'none';
   const HEADER_MODE_EDIT   = 'edit';
 
   private $panel;
   private $panelPHID;
   private $viewer;
   private $enableAsyncRendering;
   private $parentPanelPHIDs;
   private $headerMode = self::HEADER_MODE_NORMAL;
   private $movable;
   private $panelHandle;
   private $editMode;
   private $contextObject;
   private $panelKey;
 
   public function setContextObject($object) {
     $this->contextObject = $object;
     return $this;
   }
 
   public function getContextObject() {
     return $this->contextObject;
   }
 
   public function setPanelKey($panel_key) {
     $this->panelKey = $panel_key;
     return $this;
   }
 
   public function getPanelKey() {
     return $this->panelKey;
   }
 
   public function setHeaderMode($header_mode) {
     $this->headerMode = $header_mode;
     return $this;
   }
 
   public function getHeaderMode() {
     return $this->headerMode;
   }
 
   public function setPanelHandle(PhabricatorObjectHandle $panel_handle) {
     $this->panelHandle = $panel_handle;
     return $this;
   }
 
   public function getPanelHandle() {
     return $this->panelHandle;
   }
 
   public function isEditMode() {
     return $this->editMode;
   }
 
   public function setEditMode($mode) {
     $this->editMode = $mode;
     return $this;
   }
 
   /**
    * Allow the engine to render the panel via Ajax.
    */
   public function setEnableAsyncRendering($enable) {
     $this->enableAsyncRendering = $enable;
     return $this;
   }
 
   public function setParentPanelPHIDs(array $parents) {
     $this->parentPanelPHIDs = $parents;
     return $this;
   }
 
   public function getParentPanelPHIDs() {
     return $this->parentPanelPHIDs;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   public function setPanel(PhabricatorDashboardPanel $panel) {
     $this->panel = $panel;
     return $this;
   }
 
   public function setMovable($movable) {
     $this->movable = $movable;
     return $this;
   }
 
   public function getMovable() {
     return $this->movable;
   }
 
   public function getPanel() {
     return $this->panel;
   }
 
   public function setPanelPHID($panel_phid) {
     $this->panelPHID = $panel_phid;
     return $this;
   }
 
   public function getPanelPHID() {
     return $this->panelPHID;
   }
 
   public function renderPanel() {
     $panel = $this->getPanel();
 
     if (!$panel) {
       $handle = $this->getPanelHandle();
       if ($handle->getPolicyFiltered()) {
         return $this->renderErrorPanel(
           pht('Restricted Panel'),
           pht(
             'You do not have permission to see this panel.'));
       } else {
         return $this->renderErrorPanel(
           pht('Invalid Panel'),
           pht(
             'This panel is invalid or does not exist. It may have been '.
             'deleted.'));
       }
     }
 
     $panel_type = $panel->getImplementation();
     if (!$panel_type) {
       return $this->renderErrorPanel(
         $panel->getName(),
         pht(
           'This panel has type "%s", but that panel type is unknown.',
           $panel->getPanelType()));
     }
 
     try {
       $this->detectRenderingCycle($panel);
 
       if ($this->enableAsyncRendering) {
         if ($panel_type->shouldRenderAsync()) {
           return $this->renderAsyncPanel();
         }
       }
 
       return $this->renderNormalPanel();
     } catch (Exception $ex) {
       phlog($ex);
       return $this->renderErrorPanel(
         $panel->getName(),
         pht(
           '%s: %s',
           phutil_tag('strong', array(), get_class($ex)),
           $ex->getMessage()));
     }
   }
 
   private function renderNormalPanel() {
     $panel = $this->getPanel();
     $panel_type = $panel->getImplementation();
 
     $content = $panel_type->renderPanelContent(
       $this->getViewer(),
       $panel,
       $this);
     $header = $this->renderPanelHeader();
 
     return $this->renderPanelDiv(
       $content,
       $header);
   }
 
 
   private function renderAsyncPanel() {
     $context_phid = $this->getContextPHID();
     $panel = $this->getPanel();
 
     $panel_id = celerity_generate_unique_node_id();
 
     Javelin::initBehavior(
       'dashboard-async-panel',
       array(
         'panelID' => $panel_id,
         'parentPanelPHIDs' => $this->getParentPanelPHIDs(),
         'headerMode' => $this->getHeaderMode(),
         'contextPHID' => $context_phid,
         'panelKey' => $this->getPanelKey(),
         'movable' => $this->getMovable(),
         'uri' => '/dashboard/panel/render/'.$panel->getID().'/',
       ));
 
     $header = $this->renderPanelHeader();
     $content = id(new PHUIPropertyListView())
       ->addTextContent(pht('Loading...'));
 
     return $this->renderPanelDiv(
       $content,
       $header,
       $panel_id);
   }
 
   private function renderErrorPanel($title, $body) {
     switch ($this->getHeaderMode()) {
       case self::HEADER_MODE_NONE:
         $header = null;
         break;
       case self::HEADER_MODE_EDIT:
         $header = id(new PHUIHeaderView())
           ->setHeader($title);
         $header = $this->addPanelHeaderActions($header);
         break;
       case self::HEADER_MODE_NORMAL:
       default:
         $header = id(new PHUIHeaderView())
           ->setHeader($title);
         break;
     }
 
     $icon = id(new PHUIIconView())
       ->setIcon('fa-warning red msr');
 
     $content = id(new PHUIBoxView())
       ->addClass('dashboard-box')
       ->addMargin(PHUI::MARGIN_LARGE)
       ->appendChild($icon)
       ->appendChild($body);
 
     return $this->renderPanelDiv(
       $content,
       $header);
   }
 
   private function renderPanelDiv(
     $content,
     $header = null,
     $id = null) {
     require_celerity_resource('phabricator-dashboard-css');
 
     $panel = $this->getPanel();
     if (!$id) {
       $id = celerity_generate_unique_node_id();
     }
 
     $box = new PHUIObjectBoxView();
 
     $interface = 'PhabricatorApplicationSearchResultView';
     if ($content instanceof $interface) {
       if ($content->getObjectList()) {
         $box->setObjectList($content->getObjectList());
       }
       if ($content->getTable()) {
         $box->setTable($content->getTable());
       }
       if ($content->getContent()) {
         $box->appendChild($content->getContent());
       }
     } else {
       $box->appendChild($content);
     }
 
     $box
       ->setHeader($header)
       ->setID($id)
       ->addClass('dashboard-box')
       ->addSigil('dashboard-panel');
 
     // Allow to style Archived Panels differently.
     if ($panel && $panel->getIsArchived()) {
       $box->addClass('dashboard-panel-disabled');
     }
 
     if ($this->getMovable()) {
       $box->addSigil('panel-movable');
     }
 
     if ($panel) {
       $box->setMetadata(
         array(
           'panelKey' => $this->getPanelKey(),
         ));
     }
 
     return $box;
   }
 
 
   private function renderPanelHeader() {
 
     $panel = $this->getPanel();
     switch ($this->getHeaderMode()) {
       case self::HEADER_MODE_NONE:
         $header = null;
         break;
       case self::HEADER_MODE_EDIT:
         // In edit mode, include the panel monogram to make managing boards
         // a little easier.
         $header_text = pht('%s %s', $panel->getMonogram(), $panel->getName());
         $header = id(new PHUIHeaderView())
           ->setHeader($header_text);
         $header = $this->addPanelHeaderActions($header);
 
         // If the Panel is Archived, show in edit mode as such.
         if ($panel && $panel->getIsArchived()) {
           $header->setSubheader(
             id(new PHUITagView())
               ->setType(PHUITagView::TYPE_SHADE)
               ->setColor(PHUITagView::COLOR_RED)
               ->setIcon('fa-ban')
               ->setName(pht('Archived')));
         }
         break;
       case self::HEADER_MODE_NORMAL:
       default:
         $header = id(new PHUIHeaderView())
           ->setHeader($panel->getName());
         $panel_type = $panel->getImplementation();
         $header = $panel_type->adjustPanelHeader(
           $this->getViewer(),
           $panel,
           $this,
           $header);
         break;
     }
     return $header;
   }
 
   private function addPanelHeaderActions(
     PHUIHeaderView $header) {
 
     $viewer = $this->getViewer();
     $panel = $this->getPanel();
     $context_phid = $this->getContextPHID();
 
     $actions = array();
 
     if ($panel) {
       try {
         $panel_actions = $panel->newHeaderEditActions(
           $viewer,
           $context_phid);
       } catch (Exception $ex) {
         $error_action = id(new PhabricatorActionView())
           ->setIcon('fa-exclamation-triangle red')
           ->setName(pht('<Rendering Exception>'));
         $panel_actions[] = $error_action;
       }
 
       if ($panel_actions) {
         foreach ($panel_actions as $panel_action) {
           $actions[] = $panel_action;
         }
         $actions[] = id(new PhabricatorActionView())
           ->setType(PhabricatorActionView::TYPE_DIVIDER);
       }
 
       $panel_id = $panel->getID();
 
       $edit_uri = "/dashboard/panel/edit/{$panel_id}/";
       $params = array(
         'contextPHID' => $context_phid,
       );
       $edit_uri = new PhutilURI($edit_uri, $params);
 
       $actions[] = id(new PhabricatorActionView())
         ->setIcon('fa-pencil')
         ->setName(pht('Edit Panel'))
         ->setHref($edit_uri);
 
       $actions[] = id(new PhabricatorActionView())
         ->setIcon('fa-window-maximize')
         ->setName(pht('View Panel Details'))
         ->setHref($panel->getURI());
     }
 
     if ($context_phid) {
       $panel_phid = $this->getPanelPHID();
 
       $remove_uri = urisprintf('/dashboard/adjust/remove/');
       $params = array(
         'contextPHID' => $context_phid,
         'panelKey' => $this->getPanelKey(),
       );
       $remove_uri = new PhutilURI($remove_uri, $params);
 
       $actions[] = id(new PhabricatorActionView())
         ->setIcon('fa-times')
         ->setHref($remove_uri)
         ->setName(pht('Remove Panel'))
         ->setWorkflow(true);
     }
 
     $dropdown_menu = id(new PhabricatorActionListView())
       ->setViewer($viewer);
 
     foreach ($actions as $action) {
       $dropdown_menu->addAction($action);
     }
 
     $action_menu = id(new PHUIButtonView())
       ->setTag('a')
       ->setIcon('fa-cog')
       ->setText(pht('Manage Panel'))
       ->setDropdownMenu($dropdown_menu);
 
     $header->addActionLink($action_menu);
 
     return $header;
   }
 
 
   /**
    * Detect graph cycles in panels, and deeply nested panels.
    *
    * This method throws if the current rendering stack is too deep or contains
    * a cycle. This can happen if you embed layout panels inside each other,
    * build a big stack of panels, or embed a panel in remarkup inside another
    * panel. Generally, all of this stuff is ridiculous and we just want to
    * shut it down.
    *
-   * @param PhabricatorDashboardPanel Panel being rendered.
+   * @param PhabricatorDashboardPanel $panel Panel being rendered.
    * @return void
    */
   private function detectRenderingCycle(PhabricatorDashboardPanel $panel) {
     if ($this->parentPanelPHIDs === null) {
       throw new PhutilInvalidStateException('setParentPanelPHIDs');
     }
 
     $max_depth = 4;
     if (count($this->parentPanelPHIDs) >= $max_depth) {
       throw new Exception(
         pht(
           'To render more than %s levels of panels nested inside other '.
           'panels, purchase a subscription to %s Gold.',
           new PhutilNumber($max_depth),
           PlatformSymbols::getPlatformServerName()));
     }
 
     if (in_array($panel->getPHID(), $this->parentPanelPHIDs)) {
       throw new Exception(
         pht(
           'You awake in a twisting maze of mirrors, all alike. '.
           'You are likely to be eaten by a graph cycle. '.
           'Should you escape alive, you resolve to be more careful about '.
           'putting dashboard panels inside themselves.'));
     }
   }
 
   private function getContextPHID() {
     $context = $this->getContextObject();
 
     if ($context) {
       return $context->getPHID();
     }
 
     return null;
   }
 
 }
diff --git a/src/applications/differential/constants/DifferentialReviewerStatus.php b/src/applications/differential/constants/DifferentialReviewerStatus.php
index 203365efc6..c0b508f464 100644
--- a/src/applications/differential/constants/DifferentialReviewerStatus.php
+++ b/src/applications/differential/constants/DifferentialReviewerStatus.php
@@ -1,44 +1,44 @@
 <?php
 
 final class DifferentialReviewerStatus extends Phobject {
 
   const STATUS_BLOCKING = 'blocking';
   const STATUS_ADDED = 'added';
   const STATUS_ACCEPTED = 'accepted';
   const STATUS_REJECTED = 'rejected';
   const STATUS_COMMENTED = 'commented';
   const STATUS_ACCEPTED_OLDER = 'accepted-older';
   const STATUS_REJECTED_OLDER = 'rejected-older';
   const STATUS_RESIGNED = 'resigned';
 
   /**
    * Returns the relative strength of a status, used to pick a winner when a
    * transaction group makes several status changes to a particular reviewer.
    *
    * For example, if you accept a revision and leave a comment, the transactions
    * will attempt to update you to both "commented" and "accepted". We want
    * "accepted" to win, because it's the stronger of the two.
    *
-   * @param   const Reviewer status constant.
+   * @param   const $constant Reviewer status constant.
    * @return  int   Relative strength (higher is stronger).
    */
   public static function getStatusStrength($constant) {
     $map = array(
       self::STATUS_ADDED      => 1,
 
       self::STATUS_COMMENTED  => 2,
 
       self::STATUS_BLOCKING   => 3,
 
       self::STATUS_ACCEPTED_OLDER   => 4,
       self::STATUS_REJECTED_OLDER   => 4,
 
       self::STATUS_ACCEPTED   => 5,
       self::STATUS_REJECTED   => 5,
       self::STATUS_RESIGNED => 5,
     );
 
     return idx($map, $constant, 0);
   }
 
 }
diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php
index 5b39269bdd..3548e02258 100644
--- a/src/applications/differential/parser/DifferentialChangesetParser.php
+++ b/src/applications/differential/parser/DifferentialChangesetParser.php
@@ -1,1975 +1,1976 @@
 <?php
 
 final class DifferentialChangesetParser extends Phobject {
 
   const HIGHLIGHT_BYTE_LIMIT = 262144;
 
   protected $visible      = array();
   protected $new          = array();
   protected $old          = array();
   protected $intra        = array();
   protected $depthOnlyLines = array();
   protected $newRender    = null;
   protected $oldRender    = null;
 
   protected $filename     = null;
   protected $hunkStartLines = array();
 
   protected $comments     = array();
   protected $specialAttributes = array();
 
   protected $changeset;
 
   protected $renderCacheKey = null;
 
   private $handles = array();
   private $user;
 
   private $leftSideChangesetID;
   private $leftSideAttachesToNewFile;
 
   private $rightSideChangesetID;
   private $rightSideAttachesToNewFile;
 
   private $originalLeft;
   private $originalRight;
 
   private $renderingReference;
   private $isSubparser;
 
   private $isTopLevel;
 
   private $coverage;
   private $markupEngine;
   private $highlightErrors;
   private $disableCache;
   private $renderer;
   private $highlightingDisabled;
   private $showEditAndReplyLinks = true;
   private $canMarkDone;
   private $objectOwnerPHID;
   private $offsetMode;
 
   private $rangeStart;
   private $rangeEnd;
   private $mask;
   private $linesOfContext = 8;
 
   private $highlightEngine;
   private $viewer;
 
   private $viewState;
   private $availableDocumentEngines;
 
   public function setRange($start, $end) {
     $this->rangeStart = $start;
     $this->rangeEnd = $end;
     return $this;
   }
 
   public function setMask(array $mask) {
     $this->mask = $mask;
     return $this;
   }
 
   public function renderChangeset() {
     return $this->render($this->rangeStart, $this->rangeEnd, $this->mask);
   }
 
   public function setShowEditAndReplyLinks($bool) {
     $this->showEditAndReplyLinks = $bool;
     return $this;
   }
 
   public function getShowEditAndReplyLinks() {
     return $this->showEditAndReplyLinks;
   }
 
   public function setViewState(PhabricatorChangesetViewState $view_state) {
     $this->viewState = $view_state;
     return $this;
   }
 
   public function getViewState() {
     return $this->viewState;
   }
 
   public function setRenderer(DifferentialChangesetRenderer $renderer) {
     $this->renderer = $renderer;
     return $this;
   }
 
   public function getRenderer() {
     return $this->renderer;
   }
 
   public function setDisableCache($disable_cache) {
     $this->disableCache = $disable_cache;
     return $this;
   }
 
   public function getDisableCache() {
     return $this->disableCache;
   }
 
   public function setCanMarkDone($can_mark_done) {
     $this->canMarkDone = $can_mark_done;
     return $this;
   }
 
   public function getCanMarkDone() {
     return $this->canMarkDone;
   }
 
   public function setObjectOwnerPHID($phid) {
     $this->objectOwnerPHID = $phid;
     return $this;
   }
 
   public function getObjectOwnerPHID() {
     return $this->objectOwnerPHID;
   }
 
   public function setOffsetMode($offset_mode) {
     $this->offsetMode = $offset_mode;
     return $this;
   }
 
   public function getOffsetMode() {
     return $this->offsetMode;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   private function newRenderer() {
     $viewer = $this->getViewer();
     $viewstate = $this->getViewstate();
 
     $renderer_key = $viewstate->getRendererKey();
 
     if ($renderer_key === null) {
       $is_unified = $viewer->compareUserSetting(
         PhabricatorUnifiedDiffsSetting::SETTINGKEY,
         PhabricatorUnifiedDiffsSetting::VALUE_ALWAYS_UNIFIED);
 
       if ($is_unified) {
         $renderer_key = '1up';
       } else {
         $renderer_key = $viewstate->getDefaultDeviceRendererKey();
       }
     }
 
     switch ($renderer_key) {
       case '1up':
         $renderer = new DifferentialChangesetOneUpRenderer();
         break;
       default:
         $renderer = new DifferentialChangesetTwoUpRenderer();
         break;
     }
 
     return $renderer;
   }
 
   const CACHE_VERSION = 14;
   const CACHE_MAX_SIZE = 8e6;
 
   const ATTR_GENERATED  = 'attr:generated';
   const ATTR_DELETED    = 'attr:deleted';
   const ATTR_UNCHANGED  = 'attr:unchanged';
   const ATTR_MOVEAWAY   = 'attr:moveaway';
 
   public function setOldLines(array $lines) {
     $this->old = $lines;
     return $this;
   }
 
   public function setNewLines(array $lines) {
     $this->new = $lines;
     return $this;
   }
 
   public function setSpecialAttributes(array $attributes) {
     $this->specialAttributes = $attributes;
     return $this;
   }
 
   public function setIntraLineDiffs(array $diffs) {
     $this->intra = $diffs;
     return $this;
   }
 
   public function setDepthOnlyLines(array $lines) {
     $this->depthOnlyLines = $lines;
     return $this;
   }
 
   public function getDepthOnlyLines() {
     return $this->depthOnlyLines;
   }
 
   public function setVisibleLinesMask(array $mask) {
     $this->visible = $mask;
     return $this;
   }
 
   public function setLinesOfContext($lines_of_context) {
     $this->linesOfContext = $lines_of_context;
     return $this;
   }
 
   public function getLinesOfContext() {
     return $this->linesOfContext;
   }
 
 
   /**
    * Configure which Changeset comments added to the right side of the visible
    * diff will be attached to. The ID must be the ID of a real Differential
    * Changeset.
    *
    * The complexity here is that we may show an arbitrary side of an arbitrary
    * changeset as either the left or right part of a diff. This method allows
    * the left and right halves of the displayed diff to be correctly mapped to
    * storage changesets.
    *
-   * @param id    The Differential Changeset ID that comments added to the right
-   *              side of the visible diff should be attached to.
-   * @param bool  If true, attach new comments to the right side of the storage
-   *              changeset. Note that this may be false, if the left side of
-   *              some storage changeset is being shown as the right side of
-   *              a display diff.
+   * @param id    $id The Differential Changeset ID that comments added to the
+   *              right side of the visible diff should be attached to.
+   * @param bool  $is_new If true, attach new comments to the right side of the
+   *              storage changeset. Note that this may be false, if the left
+   *              side of some storage changeset is being shown as the right
+   *              side of a display diff.
    * @return this
    */
   public function setRightSideCommentMapping($id, $is_new) {
     $this->rightSideChangesetID = $id;
     $this->rightSideAttachesToNewFile = $is_new;
     return $this;
   }
 
   /**
    * See setRightSideCommentMapping(), but this sets information for the left
    * side of the display diff.
    */
   public function setLeftSideCommentMapping($id, $is_new) {
     $this->leftSideChangesetID = $id;
     $this->leftSideAttachesToNewFile = $is_new;
     return $this;
   }
 
   public function setOriginals(
     DifferentialChangeset $left,
     DifferentialChangeset $right) {
 
     $this->originalLeft = $left;
     $this->originalRight = $right;
     return $this;
   }
 
   public function diffOriginals() {
     $engine = new PhabricatorDifferenceEngine();
     $changeset = $engine->generateChangesetFromFileContent(
       implode('', mpull($this->originalLeft->getHunks(), 'getChanges')),
       implode('', mpull($this->originalRight->getHunks(), 'getChanges')));
 
     $parser = new DifferentialHunkParser();
 
     return $parser->parseHunksForHighlightMasks(
       $changeset->getHunks(),
       $this->originalLeft->getHunks(),
       $this->originalRight->getHunks());
   }
 
   /**
    * Set a key for identifying this changeset in the render cache. If set, the
    * parser will attempt to use the changeset render cache, which can improve
    * performance for frequently-viewed changesets.
    *
    * By default, there is no render cache key and parsers do not use the cache.
    * This is appropriate for rarely-viewed changesets.
    *
-   * @param   string  Key for identifying this changeset in the render cache.
+   * @param   string $key  Key for identifying this changeset in the render
+   *   cache.
    * @return  this
    */
   public function setRenderCacheKey($key) {
     $this->renderCacheKey = $key;
     return $this;
   }
 
   private function getRenderCacheKey() {
     return $this->renderCacheKey;
   }
 
   public function setChangeset(DifferentialChangeset $changeset) {
     $this->changeset = $changeset;
 
     $this->setFilename($changeset->getFilename());
 
     return $this;
   }
 
   public function setRenderingReference($ref) {
     $this->renderingReference = $ref;
     return $this;
   }
 
   private function getRenderingReference() {
     return $this->renderingReference;
   }
 
   public function getChangeset() {
     return $this->changeset;
   }
 
   public function setFilename($filename) {
     $this->filename = $filename;
     return $this;
   }
 
   public function setHandles(array $handles) {
     assert_instances_of($handles, 'PhabricatorObjectHandle');
     $this->handles = $handles;
     return $this;
   }
 
   public function setMarkupEngine(PhabricatorMarkupEngine $engine) {
     $this->markupEngine = $engine;
     return $this;
   }
 
   public function setCoverage($coverage) {
     $this->coverage = $coverage;
     return $this;
   }
   private function getCoverage() {
     return $this->coverage;
   }
 
   public function parseInlineComment(
     PhabricatorInlineComment $comment) {
 
     // Parse only comments which are actually visible.
     if ($this->isCommentVisibleOnRenderedDiff($comment)) {
       $this->comments[] = $comment;
     }
     return $this;
   }
 
   private function loadCache() {
     $render_cache_key = $this->getRenderCacheKey();
     if (!$render_cache_key) {
       return false;
     }
 
     $data = null;
 
     $changeset = new DifferentialChangeset();
     $conn_r = $changeset->establishConnection('r');
     $data = queryfx_one(
       $conn_r,
       'SELECT * FROM %T WHERE cacheIndex = %s',
       DifferentialChangeset::TABLE_CACHE,
       PhabricatorHash::digestForIndex($render_cache_key));
 
     if (!$data) {
       return false;
     }
 
     if ($data['cache'][0] == '{') {
       // This is likely an old-style JSON cache which we will not be able to
       // deserialize.
       return false;
     }
 
     $data = unserialize($data['cache']);
     if (!is_array($data) || !$data) {
       return false;
     }
 
     foreach (self::getCacheableProperties() as $cache_key) {
       if (!array_key_exists($cache_key, $data)) {
         // If we're missing a cache key, assume we're looking at an old cache
         // and ignore it.
         return false;
       }
     }
 
     if ($data['cacheVersion'] !== self::CACHE_VERSION) {
       return false;
     }
 
     // Someone displays contents of a partially cached shielded file.
     if (!isset($data['newRender']) && (!$this->isTopLevel || $this->comments)) {
       return false;
     }
 
     unset($data['cacheVersion'], $data['cacheHost']);
     $cache_prop = array_select_keys($data, self::getCacheableProperties());
     foreach ($cache_prop as $cache_key => $v) {
       $this->$cache_key = $v;
     }
 
     return true;
   }
 
   protected static function getCacheableProperties() {
     return array(
       'visible',
       'new',
       'old',
       'intra',
       'depthOnlyLines',
       'newRender',
       'oldRender',
       'specialAttributes',
       'hunkStartLines',
       'cacheVersion',
       'cacheHost',
       'highlightingDisabled',
     );
   }
 
   public function saveCache() {
     if (PhabricatorEnv::isReadOnly()) {
       return false;
     }
 
     if ($this->highlightErrors) {
       return false;
     }
 
     $render_cache_key = $this->getRenderCacheKey();
     if (!$render_cache_key) {
       return false;
     }
 
     $cache = array();
     foreach (self::getCacheableProperties() as $cache_key) {
       switch ($cache_key) {
         case 'cacheVersion':
           $cache[$cache_key] = self::CACHE_VERSION;
           break;
         case 'cacheHost':
           $cache[$cache_key] = php_uname('n');
           break;
         default:
           $cache[$cache_key] = $this->$cache_key;
           break;
       }
     }
     $cache = serialize($cache);
 
     // We don't want to waste too much space by a single changeset.
     if (strlen($cache) > self::CACHE_MAX_SIZE) {
       return;
     }
 
     $changeset = new DifferentialChangeset();
     $conn_w = $changeset->establishConnection('w');
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
       try {
         queryfx(
           $conn_w,
           'INSERT INTO %T (cacheIndex, cache, dateCreated) VALUES (%s, %B, %d)
             ON DUPLICATE KEY UPDATE cache = VALUES(cache)',
           DifferentialChangeset::TABLE_CACHE,
           PhabricatorHash::digestForIndex($render_cache_key),
           $cache,
           PhabricatorTime::getNow());
       } catch (AphrontQueryException $ex) {
         // Ignore these exceptions. A common cause is that the cache is
         // larger than 'max_allowed_packet', in which case we're better off
         // not writing it.
 
         // TODO: It would be nice to tailor this more narrowly.
       }
     unset($unguarded);
   }
 
   private function markGenerated($new_corpus_block = '') {
     $generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false);
 
     if (!$generated_guess) {
       $generated_path_regexps = PhabricatorEnv::getEnvConfig(
         'differential.generated-paths');
       foreach ($generated_path_regexps as $regexp) {
         if (preg_match($regexp, $this->changeset->getFilename())) {
           $generated_guess = true;
           break;
         }
       }
     }
 
     $event = new PhabricatorEvent(
       PhabricatorEventType::TYPE_DIFFERENTIAL_WILLMARKGENERATED,
       array(
         'corpus' => $new_corpus_block,
         'is_generated' => $generated_guess,
       )
     );
     PhutilEventEngine::dispatchEvent($event);
 
     $generated = $event->getValue('is_generated');
 
     $attribute = $this->changeset->isGeneratedChangeset();
     if ($attribute) {
       $generated = true;
     }
 
     $this->specialAttributes[self::ATTR_GENERATED] = $generated;
   }
 
   public function isGenerated() {
     return idx($this->specialAttributes, self::ATTR_GENERATED, false);
   }
 
   public function isDeleted() {
     return idx($this->specialAttributes, self::ATTR_DELETED, false);
   }
 
   public function isUnchanged() {
     return idx($this->specialAttributes, self::ATTR_UNCHANGED, false);
   }
 
   public function isMoveAway() {
     return idx($this->specialAttributes, self::ATTR_MOVEAWAY, false);
   }
 
   private function applyIntraline(&$render, $intra, $corpus) {
 
     foreach ($render as $key => $text) {
       $result = $text;
 
       if (isset($intra[$key])) {
         $result = PhabricatorDifferenceEngine::applyIntralineDiff(
           $result,
           $intra[$key]);
       }
 
       $result = $this->adjustRenderedLineForDisplay($result);
 
       $render[$key] = $result;
     }
   }
 
   private function getHighlightFuture($corpus) {
     $language = $this->getViewState()->getHighlightLanguage();
 
     if (!$language) {
       $language = $this->highlightEngine->getLanguageFromFilename(
         $this->filename);
 
       if (($language != 'txt') &&
           (strlen($corpus) > self::HIGHLIGHT_BYTE_LIMIT)) {
         $this->highlightingDisabled = true;
         $language = 'txt';
       }
     }
 
     return $this->highlightEngine->getHighlightFuture(
       $language,
       $corpus);
   }
 
   protected function processHighlightedSource($data, $result) {
 
     $result_lines = phutil_split_lines($result);
     foreach ($data as $key => $info) {
       if (!$info) {
         unset($result_lines[$key]);
       }
     }
     return $result_lines;
   }
 
   private function tryCacheStuff() {
     $changeset = $this->getChangeset();
     if (!$changeset->hasSourceTextBody()) {
 
       // TODO: This isn't really correct (the change is not "generated"), the
       // intent is just to not render a text body for Subversion directory
       // changes, etc.
       $this->markGenerated();
 
       return;
     }
 
     $viewstate = $this->getViewState();
 
     $skip_cache = false;
 
     if ($this->disableCache) {
       $skip_cache = true;
     }
 
     $character_encoding = $viewstate->getCharacterEncoding();
     if ($character_encoding !== null) {
       $skip_cache = true;
     }
 
     $highlight_language = $viewstate->getHighlightLanguage();
     if ($highlight_language !== null) {
       $skip_cache = true;
     }
 
     if ($skip_cache || !$this->loadCache()) {
       $this->process();
       if (!$skip_cache) {
         $this->saveCache();
       }
     }
   }
 
   private function process() {
     $changeset = $this->changeset;
 
     $hunk_parser = new DifferentialHunkParser();
     $hunk_parser->parseHunksForLineData($changeset->getHunks());
 
     $this->realignDiff($changeset, $hunk_parser);
 
     $hunk_parser->reparseHunksForSpecialAttributes();
 
     $unchanged = false;
     if (!$hunk_parser->getHasAnyChanges()) {
       $filetype = $this->changeset->getFileType();
       if ($filetype == DifferentialChangeType::FILE_TEXT ||
           $filetype == DifferentialChangeType::FILE_SYMLINK) {
         $unchanged = true;
       }
     }
 
     $moveaway = false;
     $changetype = $this->changeset->getChangeType();
     if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) {
       $moveaway = true;
     }
 
     $this->setSpecialAttributes(array(
       self::ATTR_UNCHANGED  => $unchanged,
       self::ATTR_DELETED    => $hunk_parser->getIsDeleted(),
       self::ATTR_MOVEAWAY   => $moveaway,
     ));
 
     $lines_context = $this->getLinesOfContext();
 
     $hunk_parser->generateIntraLineDiffs();
     $hunk_parser->generateVisibleLinesMask($lines_context);
 
     $this->setOldLines($hunk_parser->getOldLines());
     $this->setNewLines($hunk_parser->getNewLines());
     $this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs());
     $this->setDepthOnlyLines($hunk_parser->getDepthOnlyLines());
     $this->setVisibleLinesMask($hunk_parser->getVisibleLinesMask());
     $this->hunkStartLines = $hunk_parser->getHunkStartLines(
       $changeset->getHunks());
 
     $new_corpus = $hunk_parser->getNewCorpus();
     $new_corpus_block = implode('', $new_corpus);
     $this->markGenerated($new_corpus_block);
 
     if ($this->isTopLevel &&
         !$this->comments &&
           ($this->isGenerated() ||
            $this->isUnchanged() ||
            $this->isDeleted())) {
       return;
     }
 
     $old_corpus = $hunk_parser->getOldCorpus();
     $old_corpus_block = implode('', $old_corpus);
     $old_future = $this->getHighlightFuture($old_corpus_block);
     $new_future = $this->getHighlightFuture($new_corpus_block);
     $futures = array(
       'old' => $old_future,
       'new' => $new_future,
     );
     $corpus_blocks = array(
       'old' => $old_corpus_block,
       'new' => $new_corpus_block,
     );
 
     $this->highlightErrors = false;
     foreach (new FutureIterator($futures) as $key => $future) {
       try {
         try {
           $highlighted = $future->resolve();
         } catch (PhutilSyntaxHighlighterException $ex) {
           $this->highlightErrors = true;
           $highlighted = id(new PhutilDefaultSyntaxHighlighter())
             ->getHighlightFuture($corpus_blocks[$key])
             ->resolve();
         }
         switch ($key) {
         case 'old':
           $this->oldRender = $this->processHighlightedSource(
             $this->old,
             $highlighted);
           break;
         case 'new':
           $this->newRender = $this->processHighlightedSource(
             $this->new,
             $highlighted);
           break;
         }
       } catch (Exception $ex) {
         phlog($ex);
         throw $ex;
       }
     }
 
     $this->applyIntraline(
       $this->oldRender,
       ipull($this->intra, 0),
       $old_corpus);
     $this->applyIntraline(
       $this->newRender,
       ipull($this->intra, 1),
       $new_corpus);
   }
 
   private function shouldRenderPropertyChangeHeader($changeset) {
     if (!$this->isTopLevel) {
       // We render properties only at top level; otherwise we get multiple
       // copies of them when a user clicks "Show More".
       return false;
     }
 
     return true;
   }
 
   public function render(
     $range_start  = null,
     $range_len    = null,
     $mask_force   = array()) {
 
     $viewer = $this->getViewer();
 
     $renderer = $this->getRenderer();
     if (!$renderer) {
       $renderer = $this->newRenderer();
       $this->setRenderer($renderer);
     }
 
     // "Top level" renders are initial requests for the whole file, versus
     // requests for a specific range generated by clicking "show more". We
     // generate property changes and "shield" UI elements only for toplevel
     // requests.
     $this->isTopLevel = (($range_start === null) && ($range_len === null));
     $this->highlightEngine = PhabricatorSyntaxHighlighter::newEngine();
 
     $viewstate = $this->getViewState();
 
     $encoding = null;
 
     $character_encoding = $viewstate->getCharacterEncoding();
     if ($character_encoding) {
       // We are forcing this changeset to be interpreted with a specific
       // character encoding, so force all the hunks into that encoding and
       // propagate it to the renderer.
       $encoding = $character_encoding;
       foreach ($this->changeset->getHunks() as $hunk) {
         $hunk->forceEncoding($character_encoding);
       }
     } else {
       // We're just using the default, so tell the renderer what that is
       // (by reading the encoding from the first hunk).
       foreach ($this->changeset->getHunks() as $hunk) {
         $encoding = $hunk->getDataEncoding();
         break;
       }
     }
 
     $this->tryCacheStuff();
 
     // If we're rendering in an offset mode, treat the range numbers as line
     // numbers instead of rendering offsets.
     $offset_mode = $this->getOffsetMode();
     if ($offset_mode) {
       if ($offset_mode == 'new') {
         $offset_map = $this->new;
       } else {
         $offset_map = $this->old;
       }
 
       // NOTE: Inline comments use zero-based lengths. For example, a comment
       // that starts and ends on line 123 has length 0. Rendering considers
       // this range to have length 1. Probably both should agree, but that
       // ship likely sailed long ago. Tweak things here to get the two systems
       // to agree. See PHI985, where this affected mail rendering of inline
       // comments left on the final line of a file.
 
       $range_end = $this->getOffset($offset_map, $range_start + $range_len);
       $range_start = $this->getOffset($offset_map, $range_start);
       $range_len = ($range_end - $range_start) + 1;
     }
 
     $render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset);
 
     $rows = max(
       count($this->old),
       count($this->new));
 
     $renderer = $this->getRenderer()
       ->setUser($this->getViewer())
       ->setChangeset($this->changeset)
       ->setRenderPropertyChangeHeader($render_pch)
       ->setIsTopLevel($this->isTopLevel)
       ->setOldRender($this->oldRender)
       ->setNewRender($this->newRender)
       ->setHunkStartLines($this->hunkStartLines)
       ->setOldChangesetID($this->leftSideChangesetID)
       ->setNewChangesetID($this->rightSideChangesetID)
       ->setOldAttachesToNewFile($this->leftSideAttachesToNewFile)
       ->setNewAttachesToNewFile($this->rightSideAttachesToNewFile)
       ->setCodeCoverage($this->getCoverage())
       ->setRenderingReference($this->getRenderingReference())
       ->setHandles($this->handles)
       ->setOldLines($this->old)
       ->setNewLines($this->new)
       ->setOriginalCharacterEncoding($encoding)
       ->setShowEditAndReplyLinks($this->getShowEditAndReplyLinks())
       ->setCanMarkDone($this->getCanMarkDone())
       ->setObjectOwnerPHID($this->getObjectOwnerPHID())
       ->setHighlightingDisabled($this->highlightingDisabled)
       ->setDepthOnlyLines($this->getDepthOnlyLines());
 
     if ($this->markupEngine) {
       $renderer->setMarkupEngine($this->markupEngine);
     }
 
     list($engine, $old_ref, $new_ref) = $this->newDocumentEngine();
     if ($engine) {
       $engine_blocks = $engine->newEngineBlocks(
         $old_ref,
         $new_ref);
     } else {
       $engine_blocks = null;
     }
 
     $has_document_engine = ($engine_blocks !== null);
 
     // Remove empty comments that don't have any unsaved draft data.
     PhabricatorInlineComment::loadAndAttachVersionedDrafts(
       $viewer,
       $this->comments);
     foreach ($this->comments as $key => $comment) {
       if ($comment->isVoidComment($viewer)) {
         unset($this->comments[$key]);
       }
     }
 
     // See T13515. Sometimes, we collapse file content by default: for
     // example, if the file is marked as containing generated code.
 
     // If a file has inline comments, that normally means we never collapse
     // it. However, if the viewer has already collapsed all of the inlines,
     // it's fine to collapse the file.
 
     $expanded_comments = array();
     foreach ($this->comments as $comment) {
       if ($comment->isHidden()) {
         continue;
       }
       $expanded_comments[] = $comment;
     }
 
     $collapsed_count = (count($this->comments) - count($expanded_comments));
 
     $shield_raw = null;
     $shield_text = null;
     $shield_type = null;
     if ($this->isTopLevel && !$expanded_comments && !$has_document_engine) {
       if ($this->isGenerated()) {
         $shield_text = pht(
           'This file contains generated code, which does not normally '.
           'need to be reviewed.');
       } else if ($this->isMoveAway()) {
         // We put an empty shield on these files. Normally, they do not have
         // any diff content anyway. However, if they come through `arc`, they
         // may have content. We don't want to show it (it's not useful) and
         // we bailed out of fully processing it earlier anyway.
 
         // We could show a message like "this file was moved", but we show
         // that as a change header anyway, so it would be redundant. Instead,
         // just render an empty shield to skip rendering the diff body.
         $shield_raw = '';
       } else if ($this->isUnchanged()) {
         $type = 'text';
         if (!$rows) {
           // NOTE: Normally, diffs which don't change files do not include
           // file content (for example, if you "chmod +x" a file and then
           // run "git show", the file content is not available). Similarly,
           // if you move a file from A to B without changing it, diffs normally
           // do not show the file content. In some cases `arc` is able to
           // synthetically generate content for these diffs, but for raw diffs
           // we'll never have it so we need to be prepared to not render a link.
           $type = 'none';
         }
 
         $shield_type = $type;
 
         $type_add = DifferentialChangeType::TYPE_ADD;
         if ($this->changeset->getChangeType() == $type_add) {
           // Although the generic message is sort of accurate in a technical
           // sense, this more-tailored message is less confusing.
           $shield_text = pht('This is an empty file.');
         } else {
           $shield_text = pht('The contents of this file were not changed.');
         }
       } else if ($this->isDeleted()) {
         $shield_text = pht('This file was completely deleted.');
       } else if ($this->changeset->getAffectedLineCount() > 2500) {
         $shield_text = pht(
           'This file has a very large number of changes (%s lines).',
           new PhutilNumber($this->changeset->getAffectedLineCount()));
       }
     }
 
     $shield = null;
     if ($shield_raw !== null) {
       $shield = $shield_raw;
     } else if ($shield_text !== null) {
       if ($shield_type === null) {
         $shield_type = 'default';
       }
 
       // If we have inlines and the shield would normally show the whole file,
       // downgrade it to show only text around the inlines.
       if ($collapsed_count) {
         if ($shield_type === 'text') {
           $shield_type = 'default';
         }
 
         $shield_text = array(
           $shield_text,
           ' ',
           pht(
             'This file has %d collapsed inline comment(s).',
             new PhutilNumber($collapsed_count)),
         );
       }
 
       $shield = $renderer->renderShield($shield_text, $shield_type);
     }
 
     if ($shield !== null) {
       return $renderer->renderChangesetTable($shield);
     }
 
     // This request should render the "undershield" headers if it's a top-level
     // request which made it this far (indicating the changeset has no shield)
     // or it's a request with no mask information (indicating it's the request
     // that removes the rendering shield). Possibly, this second class of
     // request might need to be made more explicit.
     $is_undershield = (empty($mask_force) || $this->isTopLevel);
     $renderer->setIsUndershield($is_undershield);
 
     $old_comments = array();
     $new_comments = array();
     $old_mask = array();
     $new_mask = array();
     $feedback_mask = array();
     $lines_context = $this->getLinesOfContext();
 
     if ($this->comments) {
       // If there are any comments which appear in sections of the file which
       // we don't have, we're going to move them backwards to the closest
       // earlier line. Two cases where this may happen are:
       //
       //   - Porting ghost comments forward into a file which was mostly
       //     deleted.
       //   - Porting ghost comments forward from a full-context diff to a
       //     partial-context diff.
 
       list($old_backmap, $new_backmap) = $this->buildLineBackmaps();
 
       foreach ($this->comments as $comment) {
         $new_side = $this->isCommentOnRightSideWhenDisplayed($comment);
 
         $line = $comment->getLineNumber();
 
         // See T13524. Lint inlines from Harbormaster may not have a line
         // number.
         if ($line === null) {
           $back_line = null;
         } else if ($new_side) {
           $back_line = idx($new_backmap, $line);
         } else {
           $back_line = idx($old_backmap, $line);
         }
 
         if ($back_line != $line) {
           // TODO: This should probably be cleaner, but just be simple and
           // obvious for now.
           $ghost = $comment->getIsGhost();
           if ($ghost) {
             $moved = pht(
               'This comment originally appeared on line %s, but that line '.
               'does not exist in this version of the diff. It has been '.
               'moved backward to the nearest line.',
               new PhutilNumber($line));
             $ghost['reason'] = $ghost['reason']."\n\n".$moved;
             $comment->setIsGhost($ghost);
           }
 
           $comment->setLineNumber($back_line);
           $comment->setLineLength(0);
         }
 
         $start = max($comment->getLineNumber() - $lines_context, 0);
         $end = $comment->getLineNumber() +
           $comment->getLineLength() +
           $lines_context;
         for ($ii = $start; $ii <= $end; $ii++) {
           if ($new_side) {
             $new_mask[$ii] = true;
           } else {
             $old_mask[$ii] = true;
           }
         }
       }
 
       foreach ($this->old as $ii => $old) {
         if (isset($old['line']) && isset($old_mask[$old['line']])) {
           $feedback_mask[$ii] = true;
         }
       }
 
       foreach ($this->new as $ii => $new) {
         if (isset($new['line']) && isset($new_mask[$new['line']])) {
           $feedback_mask[$ii] = true;
         }
       }
 
       $this->comments = id(new PHUIDiffInlineThreader())
         ->reorderAndThreadCommments($this->comments);
 
       $old_max_display = 1;
       foreach ($this->old as $old) {
         if (isset($old['line'])) {
           $old_max_display = $old['line'];
         }
       }
 
       $new_max_display = 1;
       foreach ($this->new as $new) {
         if (isset($new['line'])) {
           $new_max_display = $new['line'];
         }
       }
 
       foreach ($this->comments as $comment) {
         $display_line = $comment->getLineNumber() + $comment->getLineLength();
         $display_line = max(1, $display_line);
 
         if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
           $display_line = min($new_max_display, $display_line);
           $new_comments[$display_line][] = $comment;
         } else {
           $display_line = min($old_max_display, $display_line);
           $old_comments[$display_line][] = $comment;
         }
       }
     }
 
     $renderer
       ->setOldComments($old_comments)
       ->setNewComments($new_comments);
 
     if ($engine_blocks !== null) {
       $reference = $this->getRenderingReference();
       $parts = explode('/', $reference);
       if (count($parts) == 2) {
         list($id, $vs) = $parts;
       } else {
         $id = $parts[0];
         $vs = 0;
       }
 
       // If we don't have an explicit "vs" changeset, it's the left side of
       // the "id" changeset.
       if (!$vs) {
         $vs = $id;
       }
 
       if ($mask_force) {
         $engine_blocks->setRevealedIndexes(array_keys($mask_force));
       }
 
       if ($range_start !== null || $range_len !== null) {
         $range_min = $range_start;
 
         if ($range_len === null) {
           $range_max = null;
         } else {
           $range_max = (int)$range_start + (int)$range_len;
         }
 
         $engine_blocks->setRange($range_min, $range_max);
       }
 
       $renderer
         ->setDocumentEngine($engine)
         ->setDocumentEngineBlocks($engine_blocks);
 
       return $renderer->renderDocumentEngineBlocks(
         $engine_blocks,
         (string)$id,
         (string)$vs);
     }
 
     // If we've made it here with a type of file we don't know how to render,
     // bail out with a default empty rendering. Normally, we'd expect a
     // document engine to catch these changes before we make it this far.
     switch ($this->changeset->getFileType()) {
       case DifferentialChangeType::FILE_DIRECTORY:
       case DifferentialChangeType::FILE_BINARY:
       case DifferentialChangeType::FILE_IMAGE:
         $output = $renderer->renderChangesetTable(null);
         return $output;
     }
 
     if ($this->originalLeft && $this->originalRight) {
       list($highlight_old, $highlight_new) = $this->diffOriginals();
       $highlight_old = array_flip($highlight_old);
       $highlight_new = array_flip($highlight_new);
       $renderer
         ->setHighlightOld($highlight_old)
         ->setHighlightNew($highlight_new);
     }
     $renderer
       ->setOriginalOld($this->originalLeft)
       ->setOriginalNew($this->originalRight);
 
     if ($range_start === null) {
       $range_start = 0;
     }
     if ($range_len === null) {
       $range_len = $rows;
     }
     $range_len = min($range_len, $rows - $range_start);
 
     list($gaps, $mask) = $this->calculateGapsAndMask(
       $mask_force,
       $feedback_mask,
       $range_start,
       $range_len);
 
     $renderer
       ->setGaps($gaps)
       ->setMask($mask);
 
     $html = $renderer->renderTextChange(
       $range_start,
       $range_len,
       $rows);
 
     return $renderer->renderChangesetTable($html);
   }
 
   /**
    * This function calculates a lot of stuff we need to know to display
    * the diff:
    *
    * Gaps - compute gaps in the visible display diff, where we will render
    * "Show more context" spacers. If a gap is smaller than the context size,
    * we just display it. Otherwise, we record it into $gaps and will render a
    * "show more context" element instead of diff text below. A given $gap
    * is a tuple of $gap_line_number_start and $gap_length.
    *
    * Mask - compute the actual lines that need to be shown (because they
    * are near changes lines, near inline comments, or the request has
    * explicitly asked for them, i.e. resulting from the user clicking
    * "show more"). The $mask returned is a sparsely populated dictionary
    * of $visible_line_number => true.
    *
    * @return array($gaps, $mask)
    */
   private function calculateGapsAndMask(
     $mask_force,
     $feedback_mask,
     $range_start,
     $range_len) {
 
     $lines_context = $this->getLinesOfContext();
 
     $gaps = array();
     $gap_start = 0;
     $in_gap = false;
     $base_mask = $this->visible + $mask_force + $feedback_mask;
     $base_mask[$range_start + $range_len] = true;
     for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) {
       if (isset($base_mask[$ii])) {
         if ($in_gap) {
           $gap_length = $ii - $gap_start;
           if ($gap_length <= $lines_context) {
             for ($jj = $gap_start; $jj <= $gap_start + $gap_length; $jj++) {
               $base_mask[$jj] = true;
             }
           } else {
             $gaps[] = array($gap_start, $gap_length);
           }
           $in_gap = false;
         }
       } else {
         if (!$in_gap) {
           $gap_start = $ii;
           $in_gap = true;
         }
       }
     }
     $gaps = array_reverse($gaps);
     $mask = $base_mask;
 
     return array($gaps, $mask);
   }
 
   /**
    * Determine if an inline comment will appear on the rendered diff,
    * taking into consideration which halves of which changesets will actually
    * be shown.
    *
-   * @param PhabricatorInlineComment Comment to test for visibility.
+   * @param PhabricatorInlineComment $comment Comment to test for visibility.
    * @return bool True if the comment is visible on the rendered diff.
    */
   private function isCommentVisibleOnRenderedDiff(
     PhabricatorInlineComment $comment) {
 
     $changeset_id = $comment->getChangesetID();
     $is_new = $comment->getIsNewFile();
 
     if ($changeset_id == $this->rightSideChangesetID &&
         $is_new == $this->rightSideAttachesToNewFile) {
         return true;
     }
 
     if ($changeset_id == $this->leftSideChangesetID &&
         $is_new == $this->leftSideAttachesToNewFile) {
         return true;
     }
 
     return false;
   }
 
 
   /**
    * Determine if a comment will appear on the right side of the display diff.
    * Note that the comment must appear somewhere on the rendered changeset, as
    * per isCommentVisibleOnRenderedDiff().
    *
-   * @param PhabricatorInlineComment Comment to test for display
+   * @param PhabricatorInlineComment $comment Comment to test for display
    *              location.
    * @return bool True for right, false for left.
    */
   private function isCommentOnRightSideWhenDisplayed(
     PhabricatorInlineComment $comment) {
 
     if (!$this->isCommentVisibleOnRenderedDiff($comment)) {
       throw new Exception(pht('Comment is not visible on changeset!'));
     }
 
     $changeset_id = $comment->getChangesetID();
     $is_new = $comment->getIsNewFile();
 
     if ($changeset_id == $this->rightSideChangesetID &&
         $is_new == $this->rightSideAttachesToNewFile) {
       return true;
     }
 
     return false;
   }
 
   /**
    * Parse the 'range' specification that this class and the client-side JS
    * emit to indicate that a user clicked "Show more..." on a diff. Generally,
    * use is something like this:
    *
    *   $spec = $request->getStr('range');
    *   $parsed = DifferentialChangesetParser::parseRangeSpecification($spec);
    *   list($start, $end, $mask) = $parsed;
    *   $parser->render($start, $end, $mask);
    *
-   * @param string Range specification, indicating the range of the diff that
-   *               should be rendered.
+   * @param string $spec Range specification, indicating the range of the diff
+   *               that should be rendered.
    * @return tuple List of <start, end, mask> suitable for passing to
    *               @{method:render}.
    */
   public static function parseRangeSpecification($spec) {
     $range_s = null;
     $range_e = null;
     $mask = array();
 
     if ($spec) {
       $match = null;
       if (preg_match('@^(\d+)-(\d+)(?:/(\d+)-(\d+))?$@', $spec, $match)) {
         $range_s = (int)$match[1];
         $range_e = (int)$match[2];
         if (count($match) > 3) {
           $start = (int)$match[3];
           $len = (int)$match[4];
           for ($ii = $start; $ii < $start + $len; $ii++) {
             $mask[$ii] = true;
           }
         }
       }
     }
 
     return array($range_s, $range_e, $mask);
   }
 
   /**
    * Render "modified coverage" information; test coverage on modified lines.
    * This synthesizes diff information with unit test information into a useful
    * indicator of how well tested a change is.
    */
   public function renderModifiedCoverage() {
     $na = phutil_tag('em', array(), '-');
 
     $coverage = $this->getCoverage();
     if (!$coverage) {
       return $na;
     }
 
     $covered = 0;
     $not_covered = 0;
 
     foreach ($this->new as $k => $new) {
       if ($new === null) {
         continue;
       }
 
       if (!$new['line']) {
         continue;
       }
 
       if (!$new['type']) {
         continue;
       }
 
       if (empty($coverage[$new['line'] - 1])) {
         continue;
       }
 
       switch ($coverage[$new['line'] - 1]) {
         case 'C':
           $covered++;
           break;
         case 'U':
           $not_covered++;
           break;
       }
     }
 
     if (!$covered && !$not_covered) {
       return $na;
     }
 
     return sprintf('%d%%', 100 * ($covered / ($covered + $not_covered)));
   }
 
   /**
    * Build maps from lines comments appear on to actual lines.
    */
   private function buildLineBackmaps() {
     $old_back = array();
     $new_back = array();
     foreach ($this->old as $ii => $old) {
       if ($old === null) {
         continue;
       }
       $old_back[$old['line']] = $old['line'];
     }
     foreach ($this->new as $ii => $new) {
       if ($new === null) {
         continue;
       }
       $new_back[$new['line']] = $new['line'];
     }
 
     $max_old_line = 0;
     $max_new_line = 0;
     foreach ($this->comments as $comment) {
       if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
         $max_new_line = max($max_new_line, $comment->getLineNumber());
       } else {
         $max_old_line = max($max_old_line, $comment->getLineNumber());
       }
     }
 
     $cursor = 1;
     for ($ii = 1; $ii <= $max_old_line; $ii++) {
       if (empty($old_back[$ii])) {
         $old_back[$ii] = $cursor;
       } else {
         $cursor = $old_back[$ii];
       }
     }
 
     $cursor = 1;
     for ($ii = 1; $ii <= $max_new_line; $ii++) {
       if (empty($new_back[$ii])) {
         $new_back[$ii] = $cursor;
       } else {
         $cursor = $new_back[$ii];
       }
     }
 
     return array($old_back, $new_back);
   }
 
   private function getOffset(array $map, $line) {
     if (!$map) {
       return null;
     }
 
     $line = (int)$line;
     foreach ($map as $key => $spec) {
       if ($spec && isset($spec['line'])) {
         if ((int)$spec['line'] >= $line) {
           return $key;
         }
       }
     }
 
     return $key;
   }
 
   private function realignDiff(
     DifferentialChangeset $changeset,
     DifferentialHunkParser $hunk_parser) {
     // Normalizing and realigning the diff depends on rediffing the files, and
     // we currently need complete representations of both files to do anything
     // reasonable. If we only have parts of the files, skip realignment.
 
     // We have more than one hunk, so we're definitely missing part of the file.
     $hunks = $changeset->getHunks();
     if (count($hunks) !== 1) {
       return null;
     }
 
     // The first hunk doesn't start at the beginning of the file, so we're
     // missing some context.
     $first_hunk = head($hunks);
     if ($first_hunk->getOldOffset() != 1 || $first_hunk->getNewOffset() != 1) {
       return null;
     }
 
     $old_file = $changeset->makeOldFile();
     $new_file = $changeset->makeNewFile();
     if ($old_file === $new_file) {
       // If the old and new files are exactly identical, the synthetic
       // diff below will give us nonsense and whitespace modes are
       // irrelevant anyway. This occurs when you, e.g., copy a file onto
       // itself in Subversion (see T271).
       return null;
     }
 
 
     $engine = id(new PhabricatorDifferenceEngine())
       ->setNormalize(true);
 
     $normalized_changeset = $engine->generateChangesetFromFileContent(
       $old_file,
       $new_file);
 
     $type_parser = new DifferentialHunkParser();
     $type_parser->parseHunksForLineData($normalized_changeset->getHunks());
 
     $hunk_parser->setNormalized(true);
     $hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap());
     $hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap());
   }
 
   private function adjustRenderedLineForDisplay($line) {
     // IMPORTANT: We're using "str_replace()" against raw HTML here, which can
     // easily become unsafe. The input HTML has already had syntax highlighting
     // and intraline diff highlighting applied, so it's full of "<span />" tags.
 
     static $search;
     static $replace;
     if ($search === null) {
       $rules = $this->newSuspiciousCharacterRules();
 
       $map = array();
       foreach ($rules as $key => $spec) {
         $tag = phutil_tag(
           'span',
           array(
             'data-copy-text' => $key,
             'class' => $spec['class'],
             'title' => $spec['title'],
           ),
           $spec['replacement']);
         $map[$key] = phutil_string_cast($tag);
       }
 
       $search = array_keys($map);
       $replace = array_values($map);
     }
 
     $is_html = false;
     if ($line instanceof PhutilSafeHTML) {
       $is_html = true;
       $line = hsprintf('%s', $line);
     }
 
     $line = phutil_string_cast($line);
 
     // TODO: This should be flexible, eventually.
     $tab_width = 2;
 
     $line = self::replaceTabsWithSpaces($line, $tab_width);
     $line = str_replace($search, $replace, $line);
 
     if ($is_html) {
       $line = phutil_safe_html($line);
     }
 
     return $line;
   }
 
   private function newSuspiciousCharacterRules() {
     // The "title" attributes are cached in the database, so they're
     // intentionally not wrapped in "pht(...)".
 
     $rules = array(
       "\xE2\x80\x8B" => array(
         'title' => 'ZWS',
         'class' => 'suspicious-character',
         'replacement' => '!',
       ),
       "\xC2\xA0" => array(
         'title' => 'NBSP',
         'class' => 'suspicious-character',
         'replacement' => '!',
       ),
       "\x7F" => array(
         'title' => 'DEL (0x7F)',
         'class' => 'suspicious-character',
         'replacement' => "\xE2\x90\xA1",
       ),
     );
 
     // Unicode defines special pictures for the control characters in the
     // range between "0x00" and "0x1F".
 
     $control = array(
       'NULL',
       'SOH',
       'STX',
       'ETX',
       'EOT',
       'ENQ',
       'ACK',
       'BEL',
       'BS',
       null, // "\t" Tab
       null, // "\n" New Line
       'VT',
       'FF',
       null, // "\r" Carriage Return,
       'SO',
       'SI',
       'DLE',
       'DC1',
       'DC2',
       'DC3',
       'DC4',
       'NAK',
       'SYN',
       'ETB',
       'CAN',
       'EM',
       'SUB',
       'ESC',
       'FS',
       'GS',
       'RS',
       'US',
     );
 
     foreach ($control as $idx => $label) {
       if ($label === null) {
         continue;
       }
 
       $rules[chr($idx)] = array(
         'title' => sprintf('%s (0x%02X)', $label, $idx),
         'class' => 'suspicious-character',
         'replacement' => "\xE2\x90".chr(0x80 + $idx),
       );
     }
 
     return $rules;
   }
 
   public static function replaceTabsWithSpaces($line, $tab_width) {
     static $tags = array();
     if (empty($tags[$tab_width])) {
       for ($ii = 1; $ii <= $tab_width; $ii++) {
         $tag = phutil_tag(
           'span',
           array(
             'data-copy-text' => "\t",
           ),
           str_repeat(' ', $ii));
         $tag = phutil_string_cast($tag);
         $tags[$ii] = $tag;
       }
     }
 
     // Expand all prefix tabs until we encounter any non-tab character. This
     // is cheap and often immediately produces the correct result with no
     // further work (and, particularly, no need to handle any unicode cases).
 
     $len = strlen($line);
 
     $head = 0;
     for ($head = 0; $head < $len; $head++) {
       $char = $line[$head];
       if ($char !== "\t") {
         break;
       }
     }
 
     if ($head) {
       if (empty($tags[$tab_width * $head])) {
         $tags[$tab_width * $head] = str_repeat($tags[$tab_width], $head);
       }
       $prefix = $tags[$tab_width * $head];
       $line = substr($line, $head);
     } else {
       $prefix = '';
     }
 
     // If we have no remaining tabs elsewhere in the string after taking care
     // of all the prefix tabs, we're done.
     if (strpos($line, "\t") === false) {
       return $prefix.$line;
     }
 
     $len = strlen($line);
 
     // If the line is particularly long, don't try to do anything special with
     // it. Use a faster approximation of the correct tabstop expansion instead.
     // This usually still arrives at the right result.
     if ($len > 256) {
       return $prefix.str_replace("\t", $tags[$tab_width], $line);
     }
 
     $in_tag = false;
     $pos = 0;
 
     // See PHI1210. If the line only has single-byte characters, we don't need
     // to vectorize it and can avoid an expensive UTF8 call.
 
     $fast_path = preg_match('/^[\x01-\x7F]*\z/', $line);
     if ($fast_path) {
       $replace = array();
       for ($ii = 0; $ii < $len; $ii++) {
         $char = $line[$ii];
         if ($char === '>') {
           $in_tag = false;
           continue;
         }
 
         if ($in_tag) {
           continue;
         }
 
         if ($char === '<') {
           $in_tag = true;
           continue;
         }
 
         if ($char === "\t") {
           $count = $tab_width - ($pos % $tab_width);
           $pos += $count;
           $replace[$ii] = $tags[$count];
           continue;
         }
 
         $pos++;
       }
 
       if ($replace) {
         // Apply replacements starting at the end of the string so they
         // don't mess up the offsets for following replacements.
         $replace = array_reverse($replace, true);
 
         foreach ($replace as $replace_pos => $replacement) {
           $line = substr_replace($line, $replacement, $replace_pos, 1);
         }
       }
     } else {
       $line = phutil_utf8v_combined($line);
       foreach ($line as $key => $char) {
         if ($char === '>') {
           $in_tag = false;
           continue;
         }
 
         if ($in_tag) {
           continue;
         }
 
         if ($char === '<') {
           $in_tag = true;
           continue;
         }
 
         if ($char === "\t") {
           $count = $tab_width - ($pos % $tab_width);
           $pos += $count;
           $line[$key] = $tags[$count];
           continue;
         }
 
         $pos++;
       }
 
       $line = implode('', $line);
     }
 
     return $prefix.$line;
   }
 
   private function newDocumentEngine() {
     $changeset = $this->changeset;
     $viewer = $this->getViewer();
 
     list($old_file, $new_file) = $this->loadFileObjectsForChangeset();
 
     $no_old = !$changeset->hasOldState();
     $no_new = !$changeset->hasNewState();
 
     if ($no_old) {
       $old_ref = null;
     } else {
       $old_ref = id(new PhabricatorDocumentRef())
         ->setName($changeset->getOldFile());
       if ($old_file) {
         $old_ref->setFile($old_file);
       } else {
         $old_data = $this->getRawDocumentEngineData($this->old);
         $old_ref->setData($old_data);
       }
     }
 
     if ($no_new) {
       $new_ref = null;
     } else {
       $new_ref = id(new PhabricatorDocumentRef())
         ->setName($changeset->getFilename());
       if ($new_file) {
         $new_ref->setFile($new_file);
       } else {
         $new_data = $this->getRawDocumentEngineData($this->new);
         $new_ref->setData($new_data);
       }
     }
 
     $old_engines = null;
     if ($old_ref) {
       $old_engines = PhabricatorDocumentEngine::getEnginesForRef(
         $viewer,
         $old_ref);
     }
 
     $new_engines = null;
     if ($new_ref) {
       $new_engines = PhabricatorDocumentEngine::getEnginesForRef(
         $viewer,
         $new_ref);
     }
 
     if ($new_engines !== null && $old_engines !== null) {
       $shared_engines = array_intersect_key($new_engines, $old_engines);
       $default_engine = head_key($new_engines);
     } else if ($new_engines !== null) {
       $shared_engines = $new_engines;
       $default_engine = head_key($shared_engines);
     } else if ($old_engines !== null) {
       $shared_engines = $old_engines;
       $default_engine = head_key($shared_engines);
     } else {
       return null;
     }
 
     foreach ($shared_engines as $key => $shared_engine) {
       if (!$shared_engine->canDiffDocuments($old_ref, $new_ref)) {
         unset($shared_engines[$key]);
       }
     }
 
     $this->availableDocumentEngines = $shared_engines;
 
     $viewstate = $this->getViewState();
 
     $engine_key = $viewstate->getDocumentEngineKey();
     if (phutil_nonempty_string($engine_key)) {
       if (isset($shared_engines[$engine_key])) {
         $document_engine = $shared_engines[$engine_key];
       } else {
         $document_engine = null;
       }
     } else {
       // If we aren't rendering with a specific engine, only use a default
       // engine if the best engine for the new file is a shared engine which
       // can diff files. If we're less picky (for example, by accepting any
       // shared engine) we can end up with silly behavior (like ".json" files
       // rendering as Jupyter documents).
 
       if (isset($shared_engines[$default_engine])) {
         $document_engine = $shared_engines[$default_engine];
       } else {
         $document_engine = null;
       }
     }
 
     if ($document_engine) {
       return array(
         $document_engine,
         $old_ref,
         $new_ref);
     }
 
     return null;
   }
 
   private function loadFileObjectsForChangeset() {
     $changeset = $this->changeset;
     $viewer = $this->getViewer();
 
     $old_phid = $changeset->getOldFileObjectPHID();
     $new_phid = $changeset->getNewFileObjectPHID();
 
     $old_file = null;
     $new_file = null;
 
     if ($old_phid || $new_phid) {
       $file_phids = array();
       if ($old_phid) {
         $file_phids[] = $old_phid;
       }
       if ($new_phid) {
         $file_phids[] = $new_phid;
       }
 
       $files = id(new PhabricatorFileQuery())
         ->setViewer($viewer)
         ->withPHIDs($file_phids)
         ->execute();
       $files = mpull($files, null, 'getPHID');
 
       if ($old_phid) {
         $old_file = idx($files, $old_phid);
         if (!$old_file) {
           throw new Exception(
             pht(
               'Failed to load file data for changeset ("%s").',
               $old_phid));
         }
         $changeset->attachOldFileObject($old_file);
       }
 
       if ($new_phid) {
         $new_file = idx($files, $new_phid);
         if (!$new_file) {
           throw new Exception(
             pht(
               'Failed to load file data for changeset ("%s").',
               $new_phid));
         }
         $changeset->attachNewFileObject($new_file);
       }
     }
 
     return array($old_file, $new_file);
   }
 
   public function newChangesetResponse() {
     // NOTE: This has to happen first because it has side effects. Yuck.
     $rendered_changeset = $this->renderChangeset();
 
     $renderer = $this->getRenderer();
     $renderer_key = $renderer->getRendererKey();
 
     $viewstate = $this->getViewState();
 
     $undo_templates = $renderer->renderUndoTemplates();
     foreach ($undo_templates as $key => $undo_template) {
       $undo_templates[$key] = hsprintf('%s', $undo_template);
     }
 
     $document_engine = $renderer->getDocumentEngine();
     if ($document_engine) {
       $document_engine_key = $document_engine->getDocumentEngineKey();
     } else {
       $document_engine_key = null;
     }
 
     $available_keys = array();
     $engines = $this->availableDocumentEngines;
     if (!$engines) {
       $engines = array();
     }
 
     $available_keys = mpull($engines, 'getDocumentEngineKey');
 
     // TODO: Always include "source" as a usable engine to default to
     // the buitin rendering. This is kind of a hack and does not actually
     // use the source engine. The source engine isn't a diff engine, so
     // selecting it causes us to fall through and render with builtin
     // behavior. For now, overall behavir is reasonable.
 
     $available_keys[] = PhabricatorSourceDocumentEngine::ENGINEKEY;
     $available_keys = array_fuse($available_keys);
     $available_keys = array_values($available_keys);
 
     $state = array(
       'undoTemplates' => $undo_templates,
       'rendererKey' => $renderer_key,
       'highlight' => $viewstate->getHighlightLanguage(),
       'characterEncoding' => $viewstate->getCharacterEncoding(),
       'requestDocumentEngineKey' => $viewstate->getDocumentEngineKey(),
       'responseDocumentEngineKey' => $document_engine_key,
       'availableDocumentEngineKeys' => $available_keys,
       'isHidden' => $viewstate->getHidden(),
     );
 
     return id(new PhabricatorChangesetResponse())
       ->setRenderedChangeset($rendered_changeset)
       ->setChangesetState($state);
   }
 
   private function getRawDocumentEngineData(array $lines) {
     $text = array();
 
     foreach ($lines as $line) {
       if ($line === null) {
         continue;
       }
 
       // If this is a "No newline at end of file." annotation, don't hand it
       // off to the DocumentEngine.
       if ($line['type'] === '\\') {
         continue;
       }
 
       $text[] = $line['text'];
     }
 
     return implode('', $text);
   }
 
 }
diff --git a/src/applications/differential/parser/DifferentialLineAdjustmentMap.php b/src/applications/differential/parser/DifferentialLineAdjustmentMap.php
index 2b4033c798..6c9bfad98b 100644
--- a/src/applications/differential/parser/DifferentialLineAdjustmentMap.php
+++ b/src/applications/differential/parser/DifferentialLineAdjustmentMap.php
@@ -1,375 +1,375 @@
 <?php
 
 /**
  * Datastructure which follows lines of code across source changes.
  *
  * This map is used to update the positions of inline comments after diff
  * updates. For example, if a inline comment appeared on line 30 of a diff
  * but the next update adds 15 more lines above it, the comment should move
  * down to line 45.
  *
  */
 final class DifferentialLineAdjustmentMap extends Phobject {
 
   private $map;
   private $nearestMap;
   private $isInverse;
   private $finalOffset;
   private $nextMapInChain;
 
   /**
    * Get the raw adjustment map.
    */
   public function getMap() {
     return $this->map;
   }
 
   public function getNearestMap() {
     if ($this->nearestMap === null) {
       $this->buildNearestMap();
     }
 
     return $this->nearestMap;
   }
 
   public function getFinalOffset() {
     // Make sure we've built this map already.
     $this->getNearestMap();
     return $this->finalOffset;
   }
 
 
   /**
    * Add a map to the end of the chain.
    *
    * When a line is mapped with @{method:mapLine}, it is mapped through all
    * maps in the chain.
    */
   public function addMapToChain(DifferentialLineAdjustmentMap $map) {
     if ($this->nextMapInChain) {
       $this->nextMapInChain->addMapToChain($map);
     } else {
       $this->nextMapInChain = $map;
     }
     return $this;
   }
 
 
   /**
    * Map a line across a change, or a series of changes.
    *
-   * @param int Line to map
-   * @param bool True to map it as the end of a range.
+   * @param int $line Line to map
+   * @param bool $is_end True to map it as the end of a range.
    * @return wild Spooky magic.
    */
   public function mapLine($line, $is_end) {
     $nmap = $this->getNearestMap();
 
     $deleted = false;
     $offset = false;
     if (isset($nmap[$line])) {
       $line_range = $nmap[$line];
       if ($is_end) {
         $to_line = end($line_range);
       } else {
         $to_line = reset($line_range);
       }
       if ($to_line <= 0) {
         // If we're tracing the first line and this block is collapsing,
         // compute the offset from the top of the block.
         if (!$is_end && $this->isInverse) {
           $offset = 1;
           $cursor = $line - 1;
           while (isset($nmap[$cursor])) {
             $prev = $nmap[$cursor];
             $prev = reset($prev);
             if ($prev == $to_line) {
               $offset++;
             } else {
               break;
             }
             $cursor--;
           }
         }
 
         $to_line = -$to_line;
         if (!$this->isInverse) {
           $deleted = true;
         }
       }
       $line = $to_line;
     } else {
       $line = $line + $this->finalOffset;
     }
 
     if ($this->nextMapInChain) {
       $chain = $this->nextMapInChain->mapLine($line, $is_end);
       list($chain_deleted, $chain_offset, $line) = $chain;
       $deleted = ($deleted || $chain_deleted);
       if ($chain_offset !== false) {
         if ($offset === false) {
           $offset = 0;
         }
         $offset += $chain_offset;
       }
     }
 
     return array($deleted, $offset, $line);
   }
 
 
   /**
    * Build a derived map which maps deleted lines to the nearest valid line.
    *
    * This computes a "nearest line" map and a final-line offset. These
    * derived maps allow us to map deleted code to the previous (or next) line
    * which actually exists.
    */
   private function buildNearestMap() {
     $map = $this->map;
     $nmap = array();
 
     $nearest = 0;
     foreach ($map as $key => $value) {
       if ($value) {
         $nmap[$key] = $value;
         $nearest = end($value);
       } else {
         $nmap[$key][0] = -$nearest;
       }
     }
 
     if (isset($key)) {
       $this->finalOffset = ($nearest - $key);
     } else {
       $this->finalOffset = 0;
     }
 
     foreach (array_reverse($map, true) as $key => $value) {
       if ($value) {
         $nearest = reset($value);
       } else {
         $nmap[$key][1] = -$nearest;
       }
     }
 
     $this->nearestMap = $nmap;
 
     return $this;
   }
 
   public static function newFromHunks(array $hunks) {
     assert_instances_of($hunks, 'DifferentialHunk');
 
     $map = array();
     $o = 0;
     $n = 0;
 
     $hunks = msort($hunks, 'getOldOffset');
     foreach ($hunks as $hunk) {
 
       // If the hunks are disjoint, add the implied missing lines where
       // nothing changed.
       $min = ($hunk->getOldOffset() - 1);
       while ($o < $min) {
         $o++;
         $n++;
         $map[$o][] = $n;
       }
 
       $lines = $hunk->getStructuredLines();
       foreach ($lines as $line) {
         switch ($line['type']) {
           case '-':
             $o++;
             $map[$o] = array();
             break;
           case '+':
             $n++;
             $map[$o][] = $n;
             break;
           case ' ':
             $o++;
             $n++;
             $map[$o][] = $n;
             break;
           default:
             break;
         }
       }
     }
 
     $map = self::reduceMapRanges($map);
 
     return self::newFromMap($map);
   }
 
   public static function newFromMap(array $map) {
     $obj = new DifferentialLineAdjustmentMap();
     $obj->map = $map;
     return $obj;
   }
 
   public static function newInverseMap(DifferentialLineAdjustmentMap $map) {
     $old = $map->getMap();
     $inv = array();
     $last = 0;
     foreach ($old as $k => $v) {
       if (count($v) > 1) {
         $v = range(reset($v), end($v));
       }
       if ($k == 0) {
         foreach ($v as $line) {
           $inv[$line] = array();
           $last = $line;
         }
       } else if ($v) {
         $first = true;
         foreach ($v as $line) {
           if ($first) {
             $first = false;
             $inv[$line][] = $k;
             $last = $line;
           } else {
             $inv[$line] = array();
           }
         }
       } else {
         $inv[$last][] = $k;
       }
     }
 
     $inv = self::reduceMapRanges($inv);
 
     $obj = new DifferentialLineAdjustmentMap();
     $obj->map = $inv;
     $obj->isInverse = !$map->isInverse;
     return $obj;
   }
 
   private static function reduceMapRanges(array $map) {
     foreach ($map as $key => $values) {
       if (count($values) > 2) {
         $map[$key] = array(reset($values), end($values));
       }
     }
     return $map;
   }
 
 
   public static function loadMaps(array $maps) {
     $keys = array();
     foreach ($maps as $map) {
       list($u, $v) = $map;
       $keys[self::getCacheKey($u, $v)] = $map;
     }
 
     $cache = new PhabricatorKeyValueDatabaseCache();
     $cache = new PhutilKeyValueCacheProfiler($cache);
     $cache->setProfiler(PhutilServiceProfiler::getInstance());
 
     $results = array();
 
     if ($keys) {
       $caches = $cache->getKeys(array_keys($keys));
       foreach ($caches as $key => $value) {
         list($u, $v) = $keys[$key];
         try {
           $results[$u][$v] = self::newFromMap(
             phutil_json_decode($value));
         } catch (Exception $ex) {
           // Ignore, rebuild below.
         }
         unset($keys[$key]);
       }
     }
 
     if ($keys) {
       $built = self::buildMaps($maps);
 
       $write = array();
       foreach ($built as $u => $list) {
         foreach ($list as $v => $map) {
           $write[self::getCacheKey($u, $v)] = json_encode($map->getMap());
           $results[$u][$v] = $map;
         }
       }
 
       $cache->setKeys($write);
     }
 
     return $results;
   }
 
   private static function buildMaps(array $maps) {
     $need = array();
     foreach ($maps as $map) {
       list($u, $v) = $map;
       $need[$u] = $u;
       $need[$v] = $v;
     }
 
     if ($need) {
       $changesets = id(new DifferentialChangesetQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withIDs($need)
         ->needHunks(true)
         ->execute();
       $changesets = mpull($changesets, null, 'getID');
     }
 
     $results = array();
     foreach ($maps as $map) {
       list($u, $v) = $map;
       $u_set = idx($changesets, $u);
       $v_set = idx($changesets, $v);
 
       if (!$u_set || !$v_set) {
         continue;
       }
 
       // This is the simple case.
       if ($u == $v) {
         $results[$u][$v] = self::newFromHunks(
           $u_set->getHunks());
         continue;
       }
 
       $u_old = $u_set->makeOldFile();
       $v_old = $v_set->makeOldFile();
 
       // No difference between the two left sides.
       if ($u_old == $v_old) {
         $results[$u][$v] = self::newFromMap(
           array());
         continue;
       }
 
       // If we're missing context, this won't currently work. We can
       // make this case work, but it's fairly rare.
       $u_hunks = $u_set->getHunks();
       $v_hunks = $v_set->getHunks();
       if (count($u_hunks) != 1 ||
           count($v_hunks) != 1 ||
           head($u_hunks)->getOldOffset() != 1 ||
           head($u_hunks)->getNewOffset() != 1 ||
           head($v_hunks)->getOldOffset() != 1 ||
           head($v_hunks)->getNewOffset() != 1) {
         continue;
       }
 
       $changeset = id(new PhabricatorDifferenceEngine())
         ->generateChangesetFromFileContent($u_old, $v_old);
 
       $results[$u][$v] = self::newFromHunks(
         $changeset->getHunks());
     }
 
     return $results;
   }
 
   private static function getCacheKey($u, $v) {
     return 'diffadjust.v1('.$u.','.$v.')';
   }
 
 }
diff --git a/src/applications/differential/query/DifferentialRevisionQuery.php b/src/applications/differential/query/DifferentialRevisionQuery.php
index d6b249cac5..87ebafd97f 100644
--- a/src/applications/differential/query/DifferentialRevisionQuery.php
+++ b/src/applications/differential/query/DifferentialRevisionQuery.php
@@ -1,1091 +1,1091 @@
 <?php
 
 /**
  * @task config   Query Configuration
  * @task exec     Query Execution
  * @task internal Internals
  */
 final class DifferentialRevisionQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $authors = array();
   private $draftAuthors = array();
   private $ccs = array();
   private $reviewers = array();
   private $revIDs = array();
   private $commitHashes = array();
   private $phids = array();
   private $responsibles = array();
   private $branches = array();
   private $repositoryPHIDs;
   private $updatedEpochMin;
   private $updatedEpochMax;
   private $statuses;
   private $isOpen;
   private $createdEpochMin;
   private $createdEpochMax;
   private $noReviewers;
   private $paths;
 
   const ORDER_MODIFIED      = 'order-modified';
   const ORDER_CREATED       = 'order-created';
 
   private $needActiveDiffs    = false;
   private $needDiffIDs        = false;
   private $needCommitPHIDs    = false;
   private $needHashes         = false;
   private $needReviewers = false;
   private $needReviewerAuthority;
   private $needDrafts;
   private $needFlags;
 
 
 /* -(  Query Configuration  )------------------------------------------------ */
 
   /**
    * Find revisions affecting one or more items in a list of paths.
    *
-   * @param list<string> List of file paths.
+   * @param list<string> $paths List of file paths.
    * @return this
    * @task config
    */
   public function withPaths(array $paths) {
     $this->paths = $paths;
     return $this;
   }
 
   /**
    * Filter results to revisions authored by one of the given PHIDs. Calling
    * this function will clear anything set by previous calls to
    * @{method:withAuthors}.
    *
-   * @param array List of PHIDs of authors
+   * @param array $author_phids List of PHIDs of authors
    * @return this
    * @task config
    */
   public function withAuthors(array $author_phids) {
     $this->authors = $author_phids;
     return $this;
   }
 
   /**
    * Filter results to revisions which CC one of the listed people. Calling this
    * function will clear anything set by previous calls to @{method:withCCs}.
    *
-   * @param array List of PHIDs of subscribers.
+   * @param array $cc_phids List of PHIDs of subscribers.
    * @return this
    * @task config
    */
   public function withCCs(array $cc_phids) {
     $this->ccs = $cc_phids;
     return $this;
   }
 
   /**
    * Filter results to revisions that have one of the provided PHIDs as
    * reviewers. Calling this function will clear anything set by previous calls
    * to @{method:withReviewers}.
    *
-   * @param array List of PHIDs of reviewers
+   * @param array $reviewer_phids List of PHIDs of reviewers
    * @return this
    * @task config
    */
   public function withReviewers(array $reviewer_phids) {
     if ($reviewer_phids === array()) {
       throw new Exception(
         pht(
           'Empty "withReviewers()" constraint is invalid. Provide one or '.
           'more values, or remove the constraint.'));
     }
 
     $with_none = false;
 
     foreach ($reviewer_phids as $key => $phid) {
       switch ($phid) {
         case DifferentialNoReviewersDatasource::FUNCTION_TOKEN:
           $with_none = true;
           unset($reviewer_phids[$key]);
           break;
         default:
           break;
       }
     }
 
     $this->noReviewers = $with_none;
     if ($reviewer_phids) {
       $this->reviewers = array_values($reviewer_phids);
     }
 
     return $this;
   }
 
   /**
    * Filter results to revisions that have one of the provided commit hashes.
    * Calling this function will clear anything set by previous calls to
    * @{method:withCommitHashes}.
    *
-   * @param array List of pairs <Class
+   * @param array $commit_hashes List of pairs <Class
    *              ArcanistDifferentialRevisionHash::HASH_$type constant,
    *              hash>
    * @return this
    * @task config
    */
   public function withCommitHashes(array $commit_hashes) {
     $this->commitHashes = $commit_hashes;
     return $this;
   }
 
   public function withStatuses(array $statuses) {
     $this->statuses = $statuses;
     return $this;
   }
 
   public function withIsOpen($is_open) {
     $this->isOpen = $is_open;
     return $this;
   }
 
 
   /**
    * Filter results to revisions on given branches.
    *
-   * @param  list List of branch names.
+   * @param list $branches List of branch names.
    * @return this
    * @task config
    */
   public function withBranches(array $branches) {
     $this->branches = $branches;
     return $this;
   }
 
 
   /**
    * Filter results to only return revisions whose ids are in the given set.
    *
-   * @param array List of revision ids
+   * @param array $ids List of revision ids
    * @return this
    * @task config
    */
   public function withIDs(array $ids) {
     $this->revIDs = $ids;
     return $this;
   }
 
 
   /**
    * Filter results to only return revisions whose PHIDs are in the given set.
    *
-   * @param array List of revision PHIDs
+   * @param array $phids List of revision PHIDs
    * @return this
    * @task config
    */
   public function withPHIDs(array $phids) {
     $this->phids = $phids;
     return $this;
   }
 
 
   /**
    * Given a set of users, filter results to return only revisions they are
    * responsible for (i.e., they are either authors or reviewers).
    *
-   * @param array List of user PHIDs.
+   * @param array $responsible_phids List of user PHIDs.
    * @return this
    * @task config
    */
   public function withResponsibleUsers(array $responsible_phids) {
     $this->responsibles = $responsible_phids;
     return $this;
   }
 
 
   public function withRepositoryPHIDs(array $repository_phids) {
     $this->repositoryPHIDs = $repository_phids;
     return $this;
   }
 
   public function withUpdatedEpochBetween($min, $max) {
     $this->updatedEpochMin = $min;
     $this->updatedEpochMax = $max;
     return $this;
   }
 
   public function withCreatedEpochBetween($min, $max) {
     $this->createdEpochMin = $min;
     $this->createdEpochMax = $max;
     return $this;
   }
 
 
   /**
    * Set whether or not the query should load the active diff for each
    * revision.
    *
-   * @param bool True to load and attach diffs.
+   * @param bool $need_active_diffs True to load and attach diffs.
    * @return this
    * @task config
    */
   public function needActiveDiffs($need_active_diffs) {
     $this->needActiveDiffs = $need_active_diffs;
     return $this;
   }
 
 
   /**
    * Set whether or not the query should load the associated commit PHIDs for
    * each revision.
    *
-   * @param bool True to load and attach diffs.
+   * @param bool $need_commit_phids True to load and attach diffs.
    * @return this
    * @task config
    */
   public function needCommitPHIDs($need_commit_phids) {
     $this->needCommitPHIDs = $need_commit_phids;
     return $this;
   }
 
 
   /**
    * Set whether or not the query should load associated diff IDs for each
    * revision.
    *
-   * @param bool True to load and attach diff IDs.
+   * @param bool $need_diff_ids True to load and attach diff IDs.
    * @return this
    * @task config
    */
   public function needDiffIDs($need_diff_ids) {
     $this->needDiffIDs = $need_diff_ids;
     return $this;
   }
 
 
   /**
    * Set whether or not the query should load associated commit hashes for each
    * revision.
    *
-   * @param bool True to load and attach commit hashes.
+   * @param bool $need_hashes True to load and attach commit hashes.
    * @return this
    * @task config
    */
   public function needHashes($need_hashes) {
     $this->needHashes = $need_hashes;
     return $this;
   }
 
 
   /**
    * Set whether or not the query should load associated reviewers.
    *
-   * @param bool True to load and attach reviewers.
+   * @param bool $need_reviewers True to load and attach reviewers.
    * @return this
    * @task config
    */
   public function needReviewers($need_reviewers) {
     $this->needReviewers = $need_reviewers;
     return $this;
   }
 
 
   /**
    * Request information about the viewer's authority to act on behalf of each
    * reviewer. In particular, they have authority to act on behalf of projects
    * they are a member of.
    *
-   * @param bool True to load and attach authority.
+   * @param bool $need_reviewer_authority True to load and attach authority.
    * @return this
    * @task config
    */
   public function needReviewerAuthority($need_reviewer_authority) {
     $this->needReviewerAuthority = $need_reviewer_authority;
     return $this;
   }
 
   public function needFlags($need_flags) {
     $this->needFlags = $need_flags;
     return $this;
   }
 
   public function needDrafts($need_drafts) {
     $this->needDrafts = $need_drafts;
     return $this;
   }
 
 
 /* -(  Query Execution  )---------------------------------------------------- */
 
 
   public function newResultObject() {
     return new DifferentialRevision();
   }
 
 
   /**
    * Execute the query as configured, returning matching
    * @{class:DifferentialRevision} objects.
    *
    * @return list List of matching DifferentialRevision objects.
    * @task exec
    */
   protected function loadPage() {
     $data = $this->loadData();
     $data = $this->didLoadRawRows($data);
     $table = $this->newResultObject();
     return $table->loadAllFromArray($data);
   }
 
   protected function willFilterPage(array $revisions) {
     $viewer = $this->getViewer();
 
     $repository_phids = mpull($revisions, 'getRepositoryPHID');
     $repository_phids = array_filter($repository_phids);
 
     $repositories = array();
     if ($repository_phids) {
       $repositories = id(new PhabricatorRepositoryQuery())
         ->setViewer($this->getViewer())
         ->withPHIDs($repository_phids)
         ->execute();
       $repositories = mpull($repositories, null, 'getPHID');
     }
 
     // If a revision is associated with a repository:
     //
     //   - the viewer must be able to see the repository; or
     //   - the viewer must have an automatic view capability.
     //
     // In the latter case, we'll load the revision but not load the repository.
 
     $can_view = PhabricatorPolicyCapability::CAN_VIEW;
     foreach ($revisions as $key => $revision) {
       $repo_phid = $revision->getRepositoryPHID();
       if (!$repo_phid) {
         // The revision has no associated repository. Attach `null` and move on.
         $revision->attachRepository(null);
         continue;
       }
 
       $repository = idx($repositories, $repo_phid);
       if ($repository) {
         // The revision has an associated repository, and the viewer can see
         // it. Attach it and move on.
         $revision->attachRepository($repository);
         continue;
       }
 
       if ($revision->hasAutomaticCapability($can_view, $viewer)) {
         // The revision has an associated repository which the viewer can not
         // see, but the viewer has an automatic capability on this revision.
         // Load the revision without attaching a repository.
         $revision->attachRepository(null);
         continue;
       }
 
       if ($this->getViewer()->isOmnipotent()) {
         // The viewer is omnipotent. Allow the revision to load even without
         // a repository.
         $revision->attachRepository(null);
         continue;
       }
 
       // The revision has an associated repository, and the viewer can't see
       // it, and the viewer has no special capabilities. Filter out this
       // revision.
       $this->didRejectResult($revision);
       unset($revisions[$key]);
     }
 
     if (!$revisions) {
       return array();
     }
 
     $table = new DifferentialRevision();
     $conn_r = $table->establishConnection('r');
 
     if ($this->needCommitPHIDs) {
       $this->loadCommitPHIDs($revisions);
     }
 
     $need_active = $this->needActiveDiffs;
     $need_ids = $need_active || $this->needDiffIDs;
 
     if ($need_ids) {
       $this->loadDiffIDs($conn_r, $revisions);
     }
 
     if ($need_active) {
       $this->loadActiveDiffs($conn_r, $revisions);
     }
 
     if ($this->needHashes) {
       $this->loadHashes($conn_r, $revisions);
     }
 
     if ($this->needReviewers || $this->needReviewerAuthority) {
       $this->loadReviewers($conn_r, $revisions);
     }
 
     return $revisions;
   }
 
   protected function didFilterPage(array $revisions) {
     $viewer = $this->getViewer();
 
     if ($this->needFlags) {
       $flags = id(new PhabricatorFlagQuery())
         ->setViewer($viewer)
         ->withOwnerPHIDs(array($viewer->getPHID()))
         ->withObjectPHIDs(mpull($revisions, 'getPHID'))
         ->execute();
       $flags = mpull($flags, null, 'getObjectPHID');
       foreach ($revisions as $revision) {
         $revision->attachFlag(
           $viewer,
           idx($flags, $revision->getPHID()));
       }
     }
 
     if ($this->needDrafts) {
       PhabricatorDraftEngine::attachDrafts(
         $viewer,
         $revisions);
     }
 
     return $revisions;
   }
 
   private function loadData() {
     $table = $this->newResultObject();
     $conn = $table->establishConnection('r');
 
     $selects = array();
 
     // NOTE: If the query includes "responsiblePHIDs", we execute it as a
     // UNION of revisions they own and revisions they're reviewing. This has
     // much better performance than doing it with JOIN/WHERE.
     if ($this->responsibles) {
       $basic_authors = $this->authors;
       $basic_reviewers = $this->reviewers;
 
       try {
         // Build the query where the responsible users are authors.
         $this->authors = array_merge($basic_authors, $this->responsibles);
 
         $this->reviewers = $basic_reviewers;
         $selects[] = $this->buildSelectStatement($conn);
 
         // Build the query where the responsible users are reviewers, or
         // projects they are members of are reviewers.
         $this->authors = $basic_authors;
         $this->reviewers = array_merge($basic_reviewers, $this->responsibles);
         $selects[] = $this->buildSelectStatement($conn);
 
         // Put everything back like it was.
         $this->authors = $basic_authors;
         $this->reviewers = $basic_reviewers;
       } catch (Exception $ex) {
         $this->authors = $basic_authors;
         $this->reviewers = $basic_reviewers;
         throw $ex;
       }
     } else {
       $selects[] = $this->buildSelectStatement($conn);
     }
 
     if (count($selects) > 1) {
       $unions = null;
       foreach ($selects as $select) {
         if (!$unions) {
           $unions = $select;
           continue;
         }
 
         $unions = qsprintf(
           $conn,
           '%Q UNION DISTINCT %Q',
           $unions,
           $select);
       }
 
       $query = qsprintf(
         $conn,
         '%Q %Q %Q',
         $unions,
         $this->buildOrderClause($conn, true),
         $this->buildLimitClause($conn));
     } else {
       $query = head($selects);
     }
 
     return queryfx_all($conn, '%Q', $query);
   }
 
   private function buildSelectStatement(AphrontDatabaseConnection $conn_r) {
     $table = new DifferentialRevision();
 
     $select = $this->buildSelectClause($conn_r);
 
     $from = qsprintf(
       $conn_r,
       'FROM %T r',
       $table->getTableName());
 
     $joins = $this->buildJoinsClause($conn_r);
     $where = $this->buildWhereClause($conn_r);
     $group_by = $this->buildGroupClause($conn_r);
     $having = $this->buildHavingClause($conn_r);
 
     $order_by = $this->buildOrderClause($conn_r);
 
     $limit = $this->buildLimitClause($conn_r);
 
     return qsprintf(
       $conn_r,
       '(%Q %Q %Q %Q %Q %Q %Q %Q)',
       $select,
       $from,
       $joins,
       $where,
       $group_by,
       $having,
       $order_by,
       $limit);
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * @task internal
    */
   private function buildJoinsClause(AphrontDatabaseConnection $conn) {
     $joins = array();
 
     if ($this->paths) {
       $path_table = new DifferentialAffectedPath();
       $joins[] = qsprintf(
         $conn,
         'JOIN %R paths ON paths.revisionID = r.id',
         $path_table);
     }
 
     if ($this->commitHashes) {
       $joins[] = qsprintf(
         $conn,
         'JOIN %T hash_rel ON hash_rel.revisionID = r.id',
         ArcanistDifferentialRevisionHash::TABLE_NAME);
     }
 
     if ($this->ccs) {
       $joins[] = qsprintf(
         $conn,
         'JOIN %T e_ccs ON e_ccs.src = r.phid '.
         'AND e_ccs.type = %s '.
         'AND e_ccs.dst in (%Ls)',
         PhabricatorEdgeConfig::TABLE_NAME_EDGE,
         PhabricatorObjectHasSubscriberEdgeType::EDGECONST,
         $this->ccs);
     }
 
     if ($this->reviewers) {
       $joins[] = qsprintf(
         $conn,
         'LEFT JOIN %T reviewer ON reviewer.revisionPHID = r.phid
           AND reviewer.reviewerStatus != %s
           AND reviewer.reviewerPHID in (%Ls)',
         id(new DifferentialReviewer())->getTableName(),
         DifferentialReviewerStatus::STATUS_RESIGNED,
         $this->reviewers);
     }
 
     if ($this->noReviewers) {
       $joins[] = qsprintf(
         $conn,
         'LEFT JOIN %T no_reviewer ON no_reviewer.revisionPHID = r.phid
           AND no_reviewer.reviewerStatus != %s',
         id(new DifferentialReviewer())->getTableName(),
         DifferentialReviewerStatus::STATUS_RESIGNED);
     }
 
     if ($this->draftAuthors) {
       $joins[] = qsprintf(
         $conn,
         'JOIN %T has_draft ON has_draft.srcPHID = r.phid
           AND has_draft.type = %s
           AND has_draft.dstPHID IN (%Ls)',
         PhabricatorEdgeConfig::TABLE_NAME_EDGE,
         PhabricatorObjectHasDraftEdgeType::EDGECONST,
         $this->draftAuthors);
     }
 
     $joins[] = $this->buildJoinClauseParts($conn);
 
     return $this->formatJoinClause($conn, $joins);
   }
 
 
   /**
    * @task internal
    */
   protected function buildWhereClause(AphrontDatabaseConnection $conn) {
     $viewer = $this->getViewer();
     $where = array();
 
     if ($this->paths !== null) {
       $paths = $this->paths;
 
       $path_map = id(new DiffusionPathIDQuery($paths))
         ->loadPathIDs();
 
       if (!$path_map) {
         // If none of the paths have entries in the PathID table, we can not
         // possibly find any revisions affecting them.
         throw new PhabricatorEmptyQueryException();
       }
 
       $where[] = qsprintf(
         $conn,
         'paths.pathID IN (%Ld)',
         array_fuse($path_map));
 
       // If we have repository PHIDs, additionally constrain this query to
       // try to help MySQL execute it efficiently.
       if ($this->repositoryPHIDs !== null) {
         $repositories = id(new PhabricatorRepositoryQuery())
           ->setViewer($viewer)
           ->setParentQuery($this)
           ->withPHIDs($this->repositoryPHIDs)
           ->execute();
 
         if (!$repositories) {
           throw new PhabricatorEmptyQueryException();
         }
 
         $repository_ids = mpull($repositories, 'getID');
 
         $where[] = qsprintf(
           $conn,
           'paths.repositoryID IN (%Ld)',
           $repository_ids);
       }
     }
 
     if ($this->authors) {
       $where[] = qsprintf(
         $conn,
         'r.authorPHID IN (%Ls)',
         $this->authors);
     }
 
     if ($this->revIDs) {
       $where[] = qsprintf(
         $conn,
         'r.id IN (%Ld)',
         $this->revIDs);
     }
 
     if ($this->repositoryPHIDs) {
       $where[] = qsprintf(
         $conn,
         'r.repositoryPHID IN (%Ls)',
         $this->repositoryPHIDs);
     }
 
     if ($this->commitHashes) {
       $hash_clauses = array();
       foreach ($this->commitHashes as $info) {
         list($type, $hash) = $info;
         $hash_clauses[] = qsprintf(
           $conn,
           '(hash_rel.type = %s AND hash_rel.hash = %s)',
           $type,
           $hash);
       }
       $hash_clauses = qsprintf($conn, '%LO', $hash_clauses);
       $where[] = $hash_clauses;
     }
 
     if ($this->phids) {
       $where[] = qsprintf(
         $conn,
         'r.phid IN (%Ls)',
         $this->phids);
     }
 
     if ($this->branches) {
       $where[] = qsprintf(
         $conn,
         'r.branchName in (%Ls)',
         $this->branches);
     }
 
     if ($this->updatedEpochMin !== null) {
       $where[] = qsprintf(
         $conn,
         'r.dateModified >= %d',
         $this->updatedEpochMin);
     }
 
     if ($this->updatedEpochMax !== null) {
       $where[] = qsprintf(
         $conn,
         'r.dateModified <= %d',
         $this->updatedEpochMax);
     }
 
     if ($this->createdEpochMin !== null) {
       $where[] = qsprintf(
         $conn,
         'r.dateCreated >= %d',
         $this->createdEpochMin);
     }
 
     if ($this->createdEpochMax !== null) {
       $where[] = qsprintf(
         $conn,
         'r.dateCreated <= %d',
         $this->createdEpochMax);
     }
 
     if ($this->statuses !== null) {
       $where[] = qsprintf(
         $conn,
         'r.status in (%Ls)',
         $this->statuses);
     }
 
     if ($this->isOpen !== null) {
       if ($this->isOpen) {
         $statuses = DifferentialLegacyQuery::getModernValues(
           DifferentialLegacyQuery::STATUS_OPEN);
       } else {
         $statuses = DifferentialLegacyQuery::getModernValues(
           DifferentialLegacyQuery::STATUS_CLOSED);
       }
       $where[] = qsprintf(
         $conn,
         'r.status in (%Ls)',
         $statuses);
     }
 
     $reviewer_subclauses = array();
 
     if ($this->noReviewers) {
       $reviewer_subclauses[] = qsprintf(
         $conn,
         'no_reviewer.reviewerPHID IS NULL');
     }
 
     if ($this->reviewers) {
       $reviewer_subclauses[] = qsprintf(
         $conn,
         'reviewer.reviewerPHID IS NOT NULL');
     }
 
     if ($reviewer_subclauses) {
       $where[] = qsprintf($conn, '%LO', $reviewer_subclauses);
     }
 
     $where[] = $this->buildWhereClauseParts($conn);
 
     return $this->formatWhereClause($conn, $where);
   }
 
 
   /**
    * @task internal
    */
   protected function shouldGroupQueryResultRows() {
 
     if ($this->paths) {
       // (If we have exactly one repository and exactly one path, we don't
       // technically need to group, but it's simpler to always group.)
       return true;
     }
 
     if (count($this->ccs) > 1) {
       return true;
     }
 
     if (count($this->reviewers) > 1) {
       return true;
     }
 
     if (count($this->commitHashes) > 1) {
       return true;
     }
 
     if ($this->noReviewers) {
       return true;
     }
 
     return parent::shouldGroupQueryResultRows();
   }
 
   public function getBuiltinOrders() {
     $orders = parent::getBuiltinOrders() + array(
       'updated' => array(
         'vector' => array('updated', 'id'),
         'name' => pht('Date Updated (Latest First)'),
         'aliases' => array(self::ORDER_MODIFIED),
       ),
       'outdated' => array(
         'vector' => array('-updated', '-id'),
         'name' => pht('Date Updated (Oldest First)'),
        ),
     );
 
     // Alias the "newest" builtin to the historical key for it.
     $orders['newest']['aliases'][] = self::ORDER_CREATED;
 
     return $orders;
   }
 
   protected function getDefaultOrderVector() {
     return array('updated', 'id');
   }
 
   public function getOrderableColumns() {
     return array(
       'updated' => array(
         'table' => $this->getPrimaryTableAlias(),
         'column' => 'dateModified',
         'type' => 'int',
       ),
     ) + parent::getOrderableColumns();
   }
 
   protected function newPagingMapFromPartialObject($object) {
     return array(
       'id' => (int)$object->getID(),
       'updated' => (int)$object->getDateModified(),
     );
   }
 
   private function loadCommitPHIDs(array $revisions) {
     assert_instances_of($revisions, 'DifferentialRevision');
 
     if (!$revisions) {
       return;
     }
 
     $revisions = mpull($revisions, null, 'getPHID');
 
     $edge_query = id(new PhabricatorEdgeQuery())
       ->withSourcePHIDs(array_keys($revisions))
       ->withEdgeTypes(
         array(
           DifferentialRevisionHasCommitEdgeType::EDGECONST,
         ));
     $edge_query->execute();
 
     foreach ($revisions as $phid => $revision) {
       $commit_phids = $edge_query->getDestinationPHIDs(array($phid));
       $revision->attachCommitPHIDs($commit_phids);
     }
   }
 
   private function loadDiffIDs($conn_r, array $revisions) {
     assert_instances_of($revisions, 'DifferentialRevision');
 
     $diff_table = new DifferentialDiff();
 
     $diff_ids = queryfx_all(
       $conn_r,
       'SELECT revisionID, id FROM %T WHERE revisionID IN (%Ld)
         ORDER BY id DESC',
       $diff_table->getTableName(),
       mpull($revisions, 'getID'));
     $diff_ids = igroup($diff_ids, 'revisionID');
 
     foreach ($revisions as $revision) {
       $ids = idx($diff_ids, $revision->getID(), array());
       $ids = ipull($ids, 'id');
       $revision->attachDiffIDs($ids);
     }
   }
 
   private function loadActiveDiffs($conn_r, array $revisions) {
     assert_instances_of($revisions, 'DifferentialRevision');
 
     $diff_table = new DifferentialDiff();
 
     $load_ids = array();
     foreach ($revisions as $revision) {
       $diffs = $revision->getDiffIDs();
       if ($diffs) {
         $load_ids[] = max($diffs);
       }
     }
 
     $active_diffs = array();
     if ($load_ids) {
       $active_diffs = $diff_table->loadAllWhere(
         'id IN (%Ld)',
         $load_ids);
     }
 
     $active_diffs = mpull($active_diffs, null, 'getRevisionID');
     foreach ($revisions as $revision) {
       $revision->attachActiveDiff(idx($active_diffs, $revision->getID()));
     }
   }
 
   private function loadHashes(
     AphrontDatabaseConnection $conn_r,
     array $revisions) {
     assert_instances_of($revisions, 'DifferentialRevision');
 
     $data = queryfx_all(
       $conn_r,
       'SELECT * FROM %T WHERE revisionID IN (%Ld)',
       'differential_revisionhash',
       mpull($revisions, 'getID'));
 
     $data = igroup($data, 'revisionID');
     foreach ($revisions as $revision) {
       $hashes = idx($data, $revision->getID(), array());
       $list = array();
       foreach ($hashes as $hash) {
         $list[] = array($hash['type'], $hash['hash']);
       }
       $revision->attachHashes($list);
     }
   }
 
   private function loadReviewers(
     AphrontDatabaseConnection $conn,
     array $revisions) {
 
     assert_instances_of($revisions, 'DifferentialRevision');
 
     $reviewer_table = new DifferentialReviewer();
     $reviewer_rows = queryfx_all(
       $conn,
       'SELECT * FROM %T WHERE revisionPHID IN (%Ls)
         ORDER BY id ASC',
       $reviewer_table->getTableName(),
       mpull($revisions, 'getPHID'));
     $reviewer_list = $reviewer_table->loadAllFromArray($reviewer_rows);
     $reviewer_map = mgroup($reviewer_list, 'getRevisionPHID');
 
     foreach ($reviewer_map as $key => $reviewers) {
       $reviewer_map[$key] = mpull($reviewers, null, 'getReviewerPHID');
     }
 
     $viewer = $this->getViewer();
     $viewer_phid = $viewer->getPHID();
 
     $allow_key = 'differential.allow-self-accept';
     $allow_self = PhabricatorEnv::getEnvConfig($allow_key);
 
     // Figure out which of these reviewers the viewer has authority to act as.
     if ($this->needReviewerAuthority && $viewer_phid) {
       $authority = $this->loadReviewerAuthority(
         $revisions,
         $reviewer_map,
         $allow_self);
     }
 
     foreach ($revisions as $revision) {
       $reviewers = idx($reviewer_map, $revision->getPHID(), array());
       foreach ($reviewers as $reviewer_phid => $reviewer) {
         if ($this->needReviewerAuthority) {
           if (!$viewer_phid) {
             // Logged-out users never have authority.
             $has_authority = false;
           } else if ((!$allow_self) &&
                      ($revision->getAuthorPHID() == $viewer_phid)) {
             // The author can never have authority unless we allow self-accept.
             $has_authority = false;
           } else {
             // Otherwise, look up whether the viewer has authority.
             $has_authority = isset($authority[$reviewer_phid]);
           }
 
           $reviewer->attachAuthority($viewer, $has_authority);
         }
 
         $reviewers[$reviewer_phid] = $reviewer;
       }
 
       $revision->attachReviewers($reviewers);
     }
   }
 
   private function loadReviewerAuthority(
     array $revisions,
     array $reviewers,
     $allow_self) {
 
     $revision_map = mpull($revisions, null, 'getPHID');
     $viewer_phid = $this->getViewer()->getPHID();
 
     // Find all the project/package reviewers which the user may have authority
     // over.
     $project_phids = array();
     $package_phids = array();
     $project_type = PhabricatorProjectProjectPHIDType::TYPECONST;
     $package_type = PhabricatorOwnersPackagePHIDType::TYPECONST;
 
     foreach ($reviewers as $revision_phid => $reviewer_list) {
       if (!$allow_self) {
         if ($revision_map[$revision_phid]->getAuthorPHID() == $viewer_phid) {
           // If self-review isn't permitted, the user will never have
           // authority over projects on revisions they authored because you
           // can't accept your own revisions, so we don't need to load any
           // data about these reviewers.
           continue;
         }
       }
 
       foreach ($reviewer_list as $reviewer_phid => $reviewer) {
         $phid_type = phid_get_type($reviewer_phid);
         if ($phid_type == $project_type) {
           $project_phids[] = $reviewer_phid;
         }
         if ($phid_type == $package_type) {
           $package_phids[] = $reviewer_phid;
         }
       }
     }
 
     // The viewer has authority over themselves.
     $user_authority = array_fuse(array($viewer_phid));
 
     // And over any projects they are a member of.
     $project_authority = array();
     if ($project_phids) {
       $project_authority = id(new PhabricatorProjectQuery())
         ->setViewer($this->getViewer())
         ->withPHIDs($project_phids)
         ->withMemberPHIDs(array($viewer_phid))
         ->execute();
       $project_authority = mpull($project_authority, 'getPHID');
       $project_authority = array_fuse($project_authority);
     }
 
     // And over any packages they own.
     $package_authority = array();
     if ($package_phids) {
       $package_authority = id(new PhabricatorOwnersPackageQuery())
         ->setViewer($this->getViewer())
         ->withPHIDs($package_phids)
         ->withAuthorityPHIDs(array($viewer_phid))
         ->execute();
       $package_authority = mpull($package_authority, 'getPHID');
       $package_authority = array_fuse($package_authority);
     }
 
     return $user_authority + $project_authority + $package_authority;
   }
 
   public function getQueryApplicationClass() {
     return PhabricatorDifferentialApplication::class;
   }
 
   protected function getPrimaryTableAlias() {
     return 'r';
   }
 
 }
diff --git a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php
index 7612f9e876..f6832c4fc9 100644
--- a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php
+++ b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php
@@ -1,649 +1,650 @@
 <?php
 
 abstract class DifferentialChangesetHTMLRenderer
   extends DifferentialChangesetRenderer {
 
   public static function getHTMLRendererByKey($key) {
     switch ($key) {
       case '1up':
         return new DifferentialChangesetOneUpRenderer();
       case '2up':
       default:
         return new DifferentialChangesetTwoUpRenderer();
     }
     throw new Exception(pht('Unknown HTML renderer "%s"!', $key));
   }
 
   abstract protected function getRendererTableClass();
   abstract public function getRowScaffoldForInline(
     PHUIDiffInlineCommentView $view);
 
   protected function renderChangeTypeHeader($force) {
     $changeset = $this->getChangeset();
 
     $change = $changeset->getChangeType();
     $file = $changeset->getFileType();
 
     $messages = array();
     switch ($change) {
 
       case DifferentialChangeType::TYPE_ADD:
         switch ($file) {
           case DifferentialChangeType::FILE_TEXT:
             $messages[] = pht('This file was added.');
             break;
           case DifferentialChangeType::FILE_IMAGE:
             $messages[] = pht('This image was added.');
             break;
           case DifferentialChangeType::FILE_DIRECTORY:
             $messages[] = pht('This directory was added.');
             break;
           case DifferentialChangeType::FILE_BINARY:
             $messages[] = pht('This binary file was added.');
             break;
           case DifferentialChangeType::FILE_SYMLINK:
             $messages[] = pht('This symlink was added.');
             break;
           case DifferentialChangeType::FILE_SUBMODULE:
             $messages[] = pht('This submodule was added.');
             break;
         }
         break;
 
       case DifferentialChangeType::TYPE_DELETE:
         switch ($file) {
           case DifferentialChangeType::FILE_TEXT:
             $messages[] = pht('This file was deleted.');
             break;
           case DifferentialChangeType::FILE_IMAGE:
             $messages[] = pht('This image was deleted.');
             break;
           case DifferentialChangeType::FILE_DIRECTORY:
             $messages[] = pht('This directory was deleted.');
             break;
           case DifferentialChangeType::FILE_BINARY:
             $messages[] = pht('This binary file was deleted.');
             break;
           case DifferentialChangeType::FILE_SYMLINK:
             $messages[] = pht('This symlink was deleted.');
             break;
           case DifferentialChangeType::FILE_SUBMODULE:
             $messages[] = pht('This submodule was deleted.');
             break;
         }
         break;
 
       case DifferentialChangeType::TYPE_MOVE_HERE:
         $from = phutil_tag('strong', array(), $changeset->getOldFile());
         switch ($file) {
           case DifferentialChangeType::FILE_TEXT:
             $messages[] = pht('This file was moved from %s.', $from);
             break;
           case DifferentialChangeType::FILE_IMAGE:
             $messages[] = pht('This image was moved from %s.', $from);
             break;
           case DifferentialChangeType::FILE_DIRECTORY:
             $messages[] = pht('This directory was moved from %s.', $from);
             break;
           case DifferentialChangeType::FILE_BINARY:
             $messages[] = pht('This binary file was moved from %s.', $from);
             break;
           case DifferentialChangeType::FILE_SYMLINK:
             $messages[] = pht('This symlink was moved from %s.', $from);
             break;
           case DifferentialChangeType::FILE_SUBMODULE:
             $messages[] = pht('This submodule was moved from %s.', $from);
             break;
         }
         break;
 
       case DifferentialChangeType::TYPE_COPY_HERE:
         $from = phutil_tag('strong', array(), $changeset->getOldFile());
         switch ($file) {
           case DifferentialChangeType::FILE_TEXT:
             $messages[] = pht('This file was copied from %s.', $from);
             break;
           case DifferentialChangeType::FILE_IMAGE:
             $messages[] = pht('This image was copied from %s.', $from);
             break;
           case DifferentialChangeType::FILE_DIRECTORY:
             $messages[] = pht('This directory was copied from %s.', $from);
             break;
           case DifferentialChangeType::FILE_BINARY:
             $messages[] = pht('This binary file was copied from %s.', $from);
             break;
           case DifferentialChangeType::FILE_SYMLINK:
             $messages[] = pht('This symlink was copied from %s.', $from);
             break;
           case DifferentialChangeType::FILE_SUBMODULE:
             $messages[] = pht('This submodule was copied from %s.', $from);
             break;
         }
         break;
 
       case DifferentialChangeType::TYPE_MOVE_AWAY:
         $paths = phutil_tag(
           'strong',
           array(),
           implode(', ', $changeset->getAwayPaths()));
         switch ($file) {
           case DifferentialChangeType::FILE_TEXT:
             $messages[] = pht('This file was moved to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_IMAGE:
             $messages[] = pht('This image was moved to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_DIRECTORY:
             $messages[] = pht('This directory was moved to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_BINARY:
             $messages[] = pht('This binary file was moved to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_SYMLINK:
             $messages[] = pht('This symlink was moved to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_SUBMODULE:
             $messages[] = pht('This submodule was moved to %s.', $paths);
             break;
         }
         break;
 
       case DifferentialChangeType::TYPE_COPY_AWAY:
         $paths = phutil_tag(
           'strong',
           array(),
           implode(', ', $changeset->getAwayPaths()));
         switch ($file) {
           case DifferentialChangeType::FILE_TEXT:
             $messages[] = pht('This file was copied to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_IMAGE:
             $messages[] = pht('This image was copied to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_DIRECTORY:
             $messages[] = pht('This directory was copied to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_BINARY:
             $messages[] = pht('This binary file was copied to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_SYMLINK:
             $messages[] = pht('This symlink was copied to %s.', $paths);
             break;
           case DifferentialChangeType::FILE_SUBMODULE:
             $messages[] = pht('This submodule was copied to %s.', $paths);
             break;
         }
         break;
 
       case DifferentialChangeType::TYPE_MULTICOPY:
         $paths = phutil_tag(
           'strong',
           array(),
           implode(', ', $changeset->getAwayPaths()));
         switch ($file) {
           case DifferentialChangeType::FILE_TEXT:
             $messages[] = pht(
               'This file was deleted after being copied to %s.',
               $paths);
             break;
           case DifferentialChangeType::FILE_IMAGE:
             $messages[] = pht(
               'This image was deleted after being copied to %s.',
               $paths);
             break;
           case DifferentialChangeType::FILE_DIRECTORY:
             $messages[] = pht(
               'This directory was deleted after being copied to %s.',
               $paths);
             break;
           case DifferentialChangeType::FILE_BINARY:
             $messages[] = pht(
               'This binary file was deleted after being copied to %s.',
               $paths);
             break;
           case DifferentialChangeType::FILE_SYMLINK:
             $messages[] = pht(
               'This symlink was deleted after being copied to %s.',
               $paths);
             break;
           case DifferentialChangeType::FILE_SUBMODULE:
             $messages[] = pht(
               'This submodule was deleted after being copied to %s.',
               $paths);
             break;
         }
         break;
 
       default:
         switch ($file) {
           case DifferentialChangeType::FILE_TEXT:
             // This is the default case, so we only render this header if
             // forced to since it's not very useful.
             if ($force) {
               $messages[] = pht('This file was not modified.');
             }
             break;
           case DifferentialChangeType::FILE_IMAGE:
             $messages[] = pht('This is an image.');
             break;
           case DifferentialChangeType::FILE_DIRECTORY:
             $messages[] = pht('This is a directory.');
             break;
           case DifferentialChangeType::FILE_BINARY:
             $messages[] = pht('This is a binary file.');
             break;
           case DifferentialChangeType::FILE_SYMLINK:
             $messages[] = pht('This is a symlink.');
             break;
           case DifferentialChangeType::FILE_SUBMODULE:
             $messages[] = pht('This is a submodule.');
             break;
         }
         break;
     }
 
     return $this->formatHeaderMessages($messages);
   }
 
   protected function renderUndershieldHeader() {
     $messages = array();
 
     $changeset = $this->getChangeset();
 
     $file = $changeset->getFileType();
 
     // If this is a text file with at least one hunk, we may have converted
     // the text encoding. In this case, show a note.
     $show_encoding = ($file == DifferentialChangeType::FILE_TEXT) &&
                      ($changeset->getHunks());
 
     if ($show_encoding) {
       $encoding = $this->getOriginalCharacterEncoding();
       if ($encoding != 'utf8') {
         if ($encoding) {
           $messages[] = pht(
             'This file was converted from %s for display.',
             phutil_tag('strong', array(), $encoding));
         } else {
           $messages[] = pht('This file uses an unknown character encoding.');
         }
       }
     }
 
     $blocks = $this->getDocumentEngineBlocks();
     if ($blocks) {
       foreach ($blocks->getMessages() as $message) {
         $messages[] = $message;
       }
     } else {
       if ($this->getHighlightingDisabled()) {
         $byte_limit = DifferentialChangesetParser::HIGHLIGHT_BYTE_LIMIT;
         $byte_limit = phutil_format_bytes($byte_limit);
         $messages[] = pht(
           'This file is larger than %s, so syntax highlighting is '.
           'disabled by default.',
           $byte_limit);
       }
     }
 
     return $this->formatHeaderMessages($messages);
   }
 
   private function formatHeaderMessages(array $messages) {
     if (!$messages) {
       return null;
     }
 
     foreach ($messages as $key => $message) {
       $messages[$key] = phutil_tag('li', array(), $message);
     }
 
     return phutil_tag(
       'ul',
       array(
         'class' => 'differential-meta-notice',
       ),
       $messages);
   }
 
   protected function renderPropertyChangeHeader() {
     $changeset = $this->getChangeset();
     list($old, $new) = $this->getChangesetProperties($changeset);
 
     // If we don't have any property changes, don't render this table.
     if ($old === $new) {
       return null;
     }
 
     $keys = array_keys($old + $new);
     sort($keys);
 
     $key_map = array(
       'unix:filemode' => pht('File Mode'),
       'file:dimensions' => pht('Image Dimensions'),
       'file:mimetype' => pht('MIME Type'),
       'file:size' => pht('File Size'),
     );
 
     $rows = array();
     foreach ($keys as $key) {
       $oval = idx($old, $key);
       $nval = idx($new, $key);
       if ($oval !== $nval) {
         if ($oval === null) {
           $oval = phutil_tag('em', array(), 'null');
         } else {
           $oval = phutil_escape_html_newlines($oval);
         }
 
         if ($nval === null) {
           $nval = phutil_tag('em', array(), 'null');
         } else {
           $nval = phutil_escape_html_newlines($nval);
         }
 
         $readable_key = idx($key_map, $key, $key);
 
         $row = array(
           $readable_key,
           $oval,
           $nval,
         );
         $rows[] = $row;
 
       }
     }
 
     $classes = array('', 'oval', 'nval');
     $headers = array(
       pht('Property'),
       pht('Old Value'),
       pht('New Value'),
     );
     $table = id(new AphrontTableView($rows))
       ->setHeaders($headers)
       ->setColumnClasses($classes);
     return phutil_tag(
       'div',
       array(
         'class' => 'differential-property-table',
       ),
       $table);
   }
 
   public function renderShield($message, $force = 'default') {
     $end = count($this->getOldLines());
     $reference = $this->getRenderingReference();
 
     if ($force !== 'text' &&
         $force !== 'none' &&
         $force !== 'default') {
       throw new Exception(
         pht(
           "Invalid '%s' parameter '%s'!",
           'force',
           $force));
     }
 
     $range = "0-{$end}";
     if ($force == 'text') {
       // If we're forcing text, force the whole file to be rendered.
       $range = "{$range}/0-{$end}";
     }
 
     $meta = array(
       'ref'   => $reference,
       'range' => $range,
     );
 
     $content = array();
     $content[] = $message;
     if ($force !== 'none') {
       $content[] = ' ';
       $content[] = javelin_tag(
         'a',
         array(
           'mustcapture' => true,
           'sigil'       => 'show-more',
           'class'       => 'complete',
           'href'        => '#',
           'meta'        => $meta,
         ),
         pht('Show File Contents'));
     }
 
     return $this->wrapChangeInTable(
       javelin_tag(
         'tr',
         array(
           'sigil' => 'context-target',
         ),
         phutil_tag(
           'td',
           array(
             'class' => 'differential-shield',
             'colspan' => 6,
           ),
           $content)));
   }
 
   abstract protected function renderColgroup();
 
 
   protected function wrapChangeInTable($content) {
     if (!$content) {
       return null;
     }
 
     $classes = array();
     $classes[] = 'differential-diff';
     $classes[] = 'remarkup-code';
     $classes[] = 'PhabricatorMonospaced';
     $classes[] = $this->getRendererTableClass();
 
     $sigils = array();
     $sigils[] = 'differential-diff';
     foreach ($this->getTableSigils() as $sigil) {
       $sigils[] = $sigil;
     }
 
     return javelin_tag(
       'table',
       array(
         'class' => implode(' ', $classes),
         'sigil' => implode(' ', $sigils),
       ),
       array(
         $this->renderColgroup(),
         $content,
       ));
   }
 
   protected function getTableSigils() {
     return array();
   }
 
   protected function buildInlineComment(
     PhabricatorInlineComment $comment,
     $on_right = false) {
 
     $viewer = $this->getUser();
     $edit = $viewer &&
             ($comment->getAuthorPHID() == $viewer->getPHID()) &&
             ($comment->isDraft())
             && $this->getShowEditAndReplyLinks();
     $allow_reply = (bool)$viewer && $this->getShowEditAndReplyLinks();
     $allow_done = !$comment->isDraft() && $this->getCanMarkDone();
 
     return id(new PHUIDiffInlineCommentDetailView())
       ->setViewer($viewer)
       ->setInlineComment($comment)
       ->setIsOnRight($on_right)
       ->setHandles($this->getHandles())
       ->setMarkupEngine($this->getMarkupEngine())
       ->setEditable($edit)
       ->setAllowReply($allow_reply)
       ->setCanMarkDone($allow_done)
       ->setObjectOwnerPHID($this->getObjectOwnerPHID());
   }
 
 
   /**
    * Build links which users can click to show more context in a changeset.
    *
-   * @param int Beginning of the line range to build links for.
-   * @param int Length of the line range to build links for.
-   * @param int Total number of lines in the changeset.
+   * @param int $top Beginning of the line range to build links for.
+   * @param int $len Length of the line range to build links for.
+   * @param int $changeset_length Total number of lines in the changeset.
+   * @param bool? $is_blocks
    * @return markup Rendered links.
    */
   protected function renderShowContextLinks(
     $top,
     $len,
     $changeset_length,
     $is_blocks = false) {
 
     $block_size = 20;
     $end = ($top + $len) - $block_size;
 
     // If this is a large block, such that the "top" and "bottom" ranges are
     // non-overlapping, we'll provide options to show the top, bottom or entire
     // block. For smaller blocks, we only provide an option to show the entire
     // block, since it would be silly to show the bottom 20 lines of a 25-line
     // block.
     $is_large_block = ($len > ($block_size * 2));
 
     $links = array();
 
     $block_display = new PhutilNumber($block_size);
 
     if ($is_large_block) {
       $is_first_block = ($top == 0);
       if ($is_first_block) {
         if ($is_blocks) {
           $text = pht('Show First %s Block(s)', $block_display);
         } else {
           $text = pht('Show First %s Line(s)', $block_display);
         }
       } else {
         if ($is_blocks) {
           $text = pht("\xE2\x96\xB2 Show %s Block(s)", $block_display);
         } else {
           $text = pht("\xE2\x96\xB2 Show %s Line(s)", $block_display);
         }
       }
 
       $links[] = $this->renderShowContextLink(
         false,
         "{$top}-{$len}/{$top}-20",
         $text);
     }
 
     if ($is_blocks) {
       $text = pht('Show All %s Block(s)', new PhutilNumber($len));
     } else {
       $text = pht('Show All %s Line(s)', new PhutilNumber($len));
     }
 
     $links[] = $this->renderShowContextLink(
       true,
       "{$top}-{$len}/{$top}-{$len}",
       $text);
 
     if ($is_large_block) {
       $is_last_block = (($top + $len) >= $changeset_length);
       if ($is_last_block) {
         if ($is_blocks) {
           $text = pht('Show Last %s Block(s)', $block_display);
         } else {
           $text = pht('Show Last %s Line(s)', $block_display);
         }
       } else {
         if ($is_blocks) {
           $text = pht("\xE2\x96\xBC Show %s Block(s)", $block_display);
         } else {
           $text = pht("\xE2\x96\xBC Show %s Line(s)", $block_display);
         }
       }
 
       $links[] = $this->renderShowContextLink(
         false,
         "{$top}-{$len}/{$end}-20",
         $text);
     }
 
     return phutil_implode_html(" \xE2\x80\xA2 ", $links);
   }
 
 
   /**
    * Build a link that shows more context in a changeset.
    *
    * See @{method:renderShowContextLinks}.
    *
-   * @param bool Does this link show all context when clicked?
-   * @param string Range specification for lines to show.
-   * @param string Text of the link.
+   * @param bool $is_all Does this link show all context when clicked?
+   * @param string $range Range specification for lines to show.
+   * @param string $text Text of the link.
    * @return markup Rendered link.
    */
   private function renderShowContextLink($is_all, $range, $text) {
     $reference = $this->getRenderingReference();
 
     return javelin_tag(
       'a',
       array(
         'href' => '#',
         'mustcapture' => true,
         'sigil' => 'show-more',
         'meta' => array(
           'type' => ($is_all ? 'all' : null),
           'range' => $range,
         ),
       ),
       $text);
   }
 
   /**
    * Build the prefixes for line IDs used to track inline comments.
    *
    * @return pair<wild, wild> Left and right prefixes.
    */
   protected function getLineIDPrefixes() {
     // These look like "C123NL45", which means the line is line 45 on the
     // "new" side of the file in changeset 123.
 
     // The "C" stands for "changeset", and is followed by a changeset ID.
 
     // "N" stands for "new" and means the comment should attach to the new file
     // when stored. "O" stands for "old" and means the comment should attach to
     // the old file. These are important because either the old or new part
     // of a file may appear on the left or right side of the diff in the
     // diff-of-diffs view.
 
     // The "L" stands for "line" and is followed by the line number.
 
     if ($this->getOldChangesetID()) {
       $left_prefix = array();
       $left_prefix[] = 'C';
       $left_prefix[] = $this->getOldChangesetID();
       $left_prefix[] = $this->getOldAttachesToNewFile() ? 'N' : 'O';
       $left_prefix[] = 'L';
       $left_prefix = implode('', $left_prefix);
     } else {
       $left_prefix = null;
     }
 
     if ($this->getNewChangesetID()) {
       $right_prefix = array();
       $right_prefix[] = 'C';
       $right_prefix[] = $this->getNewChangesetID();
       $right_prefix[] = $this->getNewAttachesToNewFile() ? 'N' : 'O';
       $right_prefix[] = 'L';
       $right_prefix = implode('', $right_prefix);
     } else {
       $right_prefix = null;
     }
 
     return array($left_prefix, $right_prefix);
   }
 
 }
diff --git a/src/applications/differential/render/DifferentialChangesetRenderer.php b/src/applications/differential/render/DifferentialChangesetRenderer.php
index 711d7d574b..676a6bcf05 100644
--- a/src/applications/differential/render/DifferentialChangesetRenderer.php
+++ b/src/applications/differential/render/DifferentialChangesetRenderer.php
@@ -1,774 +1,774 @@
 <?php
 
 abstract class DifferentialChangesetRenderer extends Phobject {
 
   private $user;
   private $changeset;
   private $renderingReference;
   private $renderPropertyChangeHeader;
   private $isTopLevel;
   private $isUndershield;
   private $hunkStartLines;
   private $oldLines;
   private $newLines;
   private $oldComments;
   private $newComments;
   private $oldChangesetID;
   private $newChangesetID;
   private $oldAttachesToNewFile;
   private $newAttachesToNewFile;
   private $highlightOld = array();
   private $highlightNew = array();
   private $codeCoverage;
   private $handles;
   private $markupEngine;
   private $oldRender;
   private $newRender;
   private $originalOld;
   private $originalNew;
   private $gaps;
   private $mask;
   private $originalCharacterEncoding;
   private $showEditAndReplyLinks;
   private $canMarkDone;
   private $objectOwnerPHID;
   private $highlightingDisabled;
   private $scopeEngine = false;
   private $depthOnlyLines;
 
   private $documentEngine;
   private $documentEngineBlocks;
 
   private $oldFile = false;
   private $newFile = false;
 
   abstract public function getRendererKey();
 
   public function setShowEditAndReplyLinks($bool) {
     $this->showEditAndReplyLinks = $bool;
     return $this;
   }
 
   public function getShowEditAndReplyLinks() {
     return $this->showEditAndReplyLinks;
   }
 
   public function setHighlightingDisabled($highlighting_disabled) {
     $this->highlightingDisabled = $highlighting_disabled;
     return $this;
   }
 
   public function getHighlightingDisabled() {
     return $this->highlightingDisabled;
   }
 
   public function setOriginalCharacterEncoding($original_character_encoding) {
     $this->originalCharacterEncoding = $original_character_encoding;
     return $this;
   }
 
   public function getOriginalCharacterEncoding() {
     return $this->originalCharacterEncoding;
   }
 
   public function setIsUndershield($is_undershield) {
     $this->isUndershield = $is_undershield;
     return $this;
   }
 
   public function getIsUndershield() {
     return $this->isUndershield;
   }
 
   public function setMask($mask) {
     $this->mask = $mask;
     return $this;
   }
   protected function getMask() {
     return $this->mask;
   }
 
   public function setGaps($gaps) {
     $this->gaps = $gaps;
     return $this;
   }
   protected function getGaps() {
     return $this->gaps;
   }
 
   public function setDepthOnlyLines(array $lines) {
     $this->depthOnlyLines = $lines;
     return $this;
   }
 
   public function getDepthOnlyLines() {
     return $this->depthOnlyLines;
   }
 
   public function attachOldFile(PhabricatorFile $old = null) {
     $this->oldFile = $old;
     return $this;
   }
 
   public function getOldFile() {
     if ($this->oldFile === false) {
       throw new PhabricatorDataNotAttachedException($this);
     }
     return $this->oldFile;
   }
 
   public function hasOldFile() {
     return (bool)$this->oldFile;
   }
 
   public function attachNewFile(PhabricatorFile $new = null) {
     $this->newFile = $new;
     return $this;
   }
 
   public function getNewFile() {
     if ($this->newFile === false) {
       throw new PhabricatorDataNotAttachedException($this);
     }
     return $this->newFile;
   }
 
   public function hasNewFile() {
     return (bool)$this->newFile;
   }
 
   public function setOriginalNew($original_new) {
     $this->originalNew = $original_new;
     return $this;
   }
   protected function getOriginalNew() {
     return $this->originalNew;
   }
 
   public function setOriginalOld($original_old) {
     $this->originalOld = $original_old;
     return $this;
   }
   protected function getOriginalOld() {
     return $this->originalOld;
   }
 
   public function setNewRender($new_render) {
     $this->newRender = $new_render;
     return $this;
   }
   protected function getNewRender() {
     return $this->newRender;
   }
 
   public function setOldRender($old_render) {
     $this->oldRender = $old_render;
     return $this;
   }
   protected function getOldRender() {
     return $this->oldRender;
   }
 
   public function setMarkupEngine(PhabricatorMarkupEngine $markup_engine) {
     $this->markupEngine = $markup_engine;
     return $this;
   }
   public function getMarkupEngine() {
     return $this->markupEngine;
   }
 
   public function setHandles(array $handles) {
     assert_instances_of($handles, 'PhabricatorObjectHandle');
     $this->handles = $handles;
     return $this;
   }
   protected function getHandles() {
     return $this->handles;
   }
 
   public function setCodeCoverage($code_coverage) {
     $this->codeCoverage = $code_coverage;
     return $this;
   }
   protected function getCodeCoverage() {
     return $this->codeCoverage;
   }
 
   public function setHighlightNew($highlight_new) {
     $this->highlightNew = $highlight_new;
     return $this;
   }
   protected function getHighlightNew() {
     return $this->highlightNew;
   }
 
   public function setHighlightOld($highlight_old) {
     $this->highlightOld = $highlight_old;
     return $this;
   }
   protected function getHighlightOld() {
     return $this->highlightOld;
   }
 
   public function setNewAttachesToNewFile($attaches) {
     $this->newAttachesToNewFile = $attaches;
     return $this;
   }
   protected function getNewAttachesToNewFile() {
     return $this->newAttachesToNewFile;
   }
 
   public function setOldAttachesToNewFile($attaches) {
     $this->oldAttachesToNewFile = $attaches;
     return $this;
   }
   protected function getOldAttachesToNewFile() {
     return $this->oldAttachesToNewFile;
   }
 
   public function setNewChangesetID($new_changeset_id) {
     $this->newChangesetID = $new_changeset_id;
     return $this;
   }
   protected function getNewChangesetID() {
     return $this->newChangesetID;
   }
 
   public function setOldChangesetID($old_changeset_id) {
     $this->oldChangesetID = $old_changeset_id;
     return $this;
   }
   protected function getOldChangesetID() {
     return $this->oldChangesetID;
   }
 
   public function setDocumentEngine(PhabricatorDocumentEngine $engine) {
     $this->documentEngine = $engine;
     return $this;
   }
 
   public function getDocumentEngine() {
     return $this->documentEngine;
   }
 
   public function setDocumentEngineBlocks(
     PhabricatorDocumentEngineBlocks $blocks) {
     $this->documentEngineBlocks = $blocks;
     return $this;
   }
 
   public function getDocumentEngineBlocks() {
     return $this->documentEngineBlocks;
   }
 
   public function setNewComments(array $new_comments) {
     foreach ($new_comments as $line_number => $comments) {
       assert_instances_of($comments, 'PhabricatorInlineComment');
     }
     $this->newComments = $new_comments;
     return $this;
   }
   protected function getNewComments() {
     return $this->newComments;
   }
 
   public function setOldComments(array $old_comments) {
     foreach ($old_comments as $line_number => $comments) {
       assert_instances_of($comments, 'PhabricatorInlineComment');
     }
     $this->oldComments = $old_comments;
     return $this;
   }
   protected function getOldComments() {
     return $this->oldComments;
   }
 
   public function setNewLines(array $new_lines) {
     $this->newLines = $new_lines;
     return $this;
   }
   protected function getNewLines() {
     return $this->newLines;
   }
 
   public function setOldLines(array $old_lines) {
     $this->oldLines = $old_lines;
     return $this;
   }
   protected function getOldLines() {
     return $this->oldLines;
   }
 
   public function setHunkStartLines(array $hunk_start_lines) {
     $this->hunkStartLines = $hunk_start_lines;
     return $this;
   }
 
   protected function getHunkStartLines() {
     return $this->hunkStartLines;
   }
 
   public function setUser(PhabricatorUser $user) {
     $this->user = $user;
     return $this;
   }
   protected function getUser() {
     return $this->user;
   }
 
   public function setChangeset(DifferentialChangeset $changeset) {
     $this->changeset = $changeset;
     return $this;
   }
   protected function getChangeset() {
     return $this->changeset;
   }
 
   public function setRenderingReference($rendering_reference) {
     $this->renderingReference = $rendering_reference;
     return $this;
   }
   protected function getRenderingReference() {
     return $this->renderingReference;
   }
 
   public function setRenderPropertyChangeHeader($should_render) {
     $this->renderPropertyChangeHeader = $should_render;
     return $this;
   }
 
   private function shouldRenderPropertyChangeHeader() {
     return $this->renderPropertyChangeHeader;
   }
 
   public function setIsTopLevel($is) {
     $this->isTopLevel = $is;
     return $this;
   }
 
   private function getIsTopLevel() {
     return $this->isTopLevel;
   }
 
   public function setCanMarkDone($can_mark_done) {
     $this->canMarkDone = $can_mark_done;
     return $this;
   }
 
   public function getCanMarkDone() {
     return $this->canMarkDone;
   }
 
   public function setObjectOwnerPHID($phid) {
     $this->objectOwnerPHID = $phid;
     return $this;
   }
 
   public function getObjectOwnerPHID() {
     return $this->objectOwnerPHID;
   }
 
   final public function renderChangesetTable($content) {
     $props = null;
     if ($this->shouldRenderPropertyChangeHeader()) {
       $props = $this->renderPropertyChangeHeader();
     }
 
     $notice = null;
     if ($this->getIsTopLevel()) {
       $force = (!$content && !$props);
 
       // If we have DocumentEngine messages about the blocks, assume they
       // explain why there's no content.
       $blocks = $this->getDocumentEngineBlocks();
       if ($blocks) {
         if ($blocks->getMessages()) {
           $force = false;
         }
       }
 
       $notice = $this->renderChangeTypeHeader($force);
     }
 
     $undershield = null;
     if ($this->getIsUndershield()) {
       $undershield = $this->renderUndershieldHeader();
     }
 
     $result = array(
       $notice,
       $props,
       $undershield,
       $content,
     );
 
     return hsprintf('%s', $result);
   }
 
   abstract public function isOneUpRenderer();
   abstract public function renderTextChange(
     $range_start,
     $range_len,
     $rows);
 
   public function renderDocumentEngineBlocks(
     PhabricatorDocumentEngineBlocks $blocks,
     $old_changeset_key,
     $new_changeset_key) {
     return null;
   }
 
   abstract protected function renderChangeTypeHeader($force);
   abstract protected function renderUndershieldHeader();
 
   protected function didRenderChangesetTableContents($contents) {
     return $contents;
   }
 
   /**
    * Render a "shield" over the diff, with a message like "This file is
    * generated and does not need to be reviewed." or "This file was completely
    * deleted." This UI element hides unimportant text so the reviewer doesn't
    * need to scroll past it.
    *
    * The shield includes a link to view the underlying content. This link
    * may force certain rendering modes when the link is clicked:
    *
    *    - `"default"`: Render the diff normally, as though it was not
    *      shielded. This is the default and appropriate if the underlying
    *      diff is a normal change, but was hidden for reasons of not being
    *      important (e.g., generated code).
    *    - `"text"`: Force the text to be shown. This is probably only relevant
    *      when a file is not changed.
    *    - `"none"`: Don't show the link (e.g., text not available).
    *
-   * @param   string        Message explaining why the diff is hidden.
-   * @param   string|null   Force mode, see above.
+   * @param   string        $message Message explaining why the diff is hidden.
+   * @param   string|null   $force Force mode, see above.
    * @return  string        Shield markup.
    */
   abstract public function renderShield($message, $force = 'default');
 
   abstract protected function renderPropertyChangeHeader();
 
   protected function buildPrimitives($range_start, $range_len) {
     $primitives = array();
 
     $hunk_starts = $this->getHunkStartLines();
 
     $mask = $this->getMask();
     $gaps = $this->getGaps();
 
     $old = $this->getOldLines();
     $new = $this->getNewLines();
     $old_render = $this->getOldRender();
     $new_render = $this->getNewRender();
     $old_comments = $this->getOldComments();
     $new_comments = $this->getNewComments();
 
     $size = count($old);
     for ($ii = $range_start; $ii < $range_start + $range_len; $ii++) {
       if (empty($mask[$ii])) {
         list($top, $len) = array_pop($gaps);
         $primitives[] = array(
           'type' => 'context',
           'top' => $top,
           'len' => $len,
         );
 
         $ii += ($len - 1);
         continue;
       }
 
       $ospec = array(
         'type' => 'old',
         'htype' => null,
         'cursor' => $ii,
         'line' => null,
         'oline' => null,
         'render' => null,
       );
 
       $nspec = array(
         'type' => 'new',
         'htype' => null,
         'cursor' => $ii,
         'line' => null,
         'oline' => null,
         'render' => null,
         'copy' => null,
         'coverage' => null,
       );
 
       if (isset($old[$ii])) {
         $ospec['line'] = (int)$old[$ii]['line'];
         $nspec['oline'] = (int)$old[$ii]['line'];
         $ospec['htype'] = $old[$ii]['type'];
         if (isset($old_render[$ii])) {
           $ospec['render'] = $old_render[$ii];
         } else if ($ospec['htype'] === '\\') {
           $ospec['render'] = $old[$ii]['text'];
         }
       }
 
       if (isset($new[$ii])) {
         $nspec['line'] = (int)$new[$ii]['line'];
         $ospec['oline'] = (int)$new[$ii]['line'];
         $nspec['htype'] = $new[$ii]['type'];
         if (isset($new_render[$ii])) {
           $nspec['render'] = $new_render[$ii];
         } else if ($nspec['htype'] === '\\') {
           $nspec['render'] = $new[$ii]['text'];
         }
       }
 
       if (isset($hunk_starts[$ospec['line']])) {
         $primitives[] = array(
           'type' => 'no-context',
         );
       }
 
       $primitives[] = $ospec;
       $primitives[] = $nspec;
 
       if ($ospec['line'] !== null && isset($old_comments[$ospec['line']])) {
         foreach ($old_comments[$ospec['line']] as $comment) {
           $primitives[] = array(
             'type' => 'inline',
             'comment' => $comment,
             'right' => false,
           );
         }
       }
 
       if ($nspec['line'] !== null && isset($new_comments[$nspec['line']])) {
         foreach ($new_comments[$nspec['line']] as $comment) {
           $primitives[] = array(
             'type' => 'inline',
             'comment' => $comment,
             'right' => true,
           );
         }
       }
 
       if ($hunk_starts && ($ii == $size - 1)) {
         $primitives[] = array(
           'type' => 'no-context',
         );
       }
     }
 
     if ($this->isOneUpRenderer()) {
       $primitives = $this->processPrimitivesForOneUp($primitives);
     }
 
     return $primitives;
   }
 
   private function processPrimitivesForOneUp(array $primitives) {
     // Primitives come out of buildPrimitives() in two-up format, because it
     // is the most general, flexible format. To put them into one-up format,
     // we need to filter and reorder them. In particular:
     //
     //   - We discard unchanged lines in the old file; in one-up format, we
     //     render them only once.
     //   - We group contiguous blocks of old-modified and new-modified lines, so
     //     they render in "block of old, block of new" order instead of
     //     alternating old and new lines.
 
     $out = array();
 
     $old_buf = array();
     $new_buf = array();
     foreach ($primitives as $primitive) {
       $type = $primitive['type'];
 
       if ($type == 'old') {
         if (!$primitive['htype']) {
           // This is a line which appears in both the old file and the new
           // file, or the spacer corresponding to a line added in the new file.
           // Ignore it when rendering a one-up diff.
           continue;
         }
         $old_buf[] = $primitive;
       } else if ($type == 'new') {
         if ($primitive['line'] === null) {
           // This is an empty spacer corresponding to a line removed from the
           // old file. Ignore it when rendering a one-up diff.
           continue;
         }
         if (!$primitive['htype']) {
           // If this line is the same in both versions of the file, put it in
           // the old line buffer. This makes sure inlines on old, unchanged
           // lines end up in the right place.
 
           // First, we need to flush the line buffers if they're not empty.
           if ($old_buf) {
             $out[] = $old_buf;
             $old_buf = array();
           }
           if ($new_buf) {
             $out[] = $new_buf;
             $new_buf = array();
           }
           $old_buf[] = $primitive;
         } else {
           $new_buf[] = $primitive;
         }
       } else if ($type == 'context' || $type == 'no-context') {
         $out[] = $old_buf;
         $out[] = $new_buf;
         $old_buf = array();
         $new_buf = array();
         $out[] = array($primitive);
       } else if ($type == 'inline') {
 
         // If this inline is on the left side, put it after the old lines.
         if (!$primitive['right']) {
           $out[] = $old_buf;
           $out[] = array($primitive);
           $old_buf = array();
         } else {
           $out[] = $old_buf;
           $out[] = $new_buf;
           $out[] = array($primitive);
           $old_buf = array();
           $new_buf = array();
         }
 
       } else {
         throw new Exception(pht("Unknown primitive type '%s'!", $primitive));
       }
     }
 
     $out[] = $old_buf;
     $out[] = $new_buf;
     $out = array_mergev($out);
 
     return $out;
   }
 
   protected function getChangesetProperties($changeset) {
     $old = $changeset->getOldProperties();
     $new = $changeset->getNewProperties();
 
     // If a property has been changed, but is not present on one side of the
     // change and has an uninteresting default value on the other, remove it.
     // This most commonly happens when a change adds or removes a file: the
     // side of the change with the file has a "100644" filemode in Git.
 
     $defaults = array(
       'unix:filemode' => '100644',
     );
 
     foreach ($defaults as $default_key => $default_value) {
       $old_value = idx($old, $default_key, $default_value);
       $new_value = idx($new, $default_key, $default_value);
 
       $old_default = ($old_value === $default_value);
       $new_default = ($new_value === $default_value);
 
       if ($old_default && $new_default) {
         unset($old[$default_key]);
         unset($new[$default_key]);
       }
     }
 
     $metadata = $changeset->getMetadata();
 
     if ($this->hasOldFile()) {
       $file = $this->getOldFile();
       if ($file->getImageWidth()) {
         $dimensions = $file->getImageWidth().'x'.$file->getImageHeight();
         $old['file:dimensions'] = $dimensions;
       }
       $old['file:mimetype'] = $file->getMimeType();
       $old['file:size'] = phutil_format_bytes($file->getByteSize());
     } else {
       $old['file:mimetype'] = idx($metadata, 'old:file:mime-type');
       $size = idx($metadata, 'old:file:size');
       if ($size !== null) {
         $old['file:size'] = phutil_format_bytes($size);
       }
     }
 
     if ($this->hasNewFile()) {
       $file = $this->getNewFile();
       if ($file->getImageWidth()) {
         $dimensions = $file->getImageWidth().'x'.$file->getImageHeight();
         $new['file:dimensions'] = $dimensions;
       }
       $new['file:mimetype'] = $file->getMimeType();
       $new['file:size'] = phutil_format_bytes($file->getByteSize());
     } else {
       $new['file:mimetype'] = idx($metadata, 'new:file:mime-type');
       $size = idx($metadata, 'new:file:size');
       if ($size !== null) {
         $new['file:size'] = phutil_format_bytes($size);
       }
     }
 
     return array($old, $new);
   }
 
   public function renderUndoTemplates() {
     $views = array(
       'l' => id(new PHUIDiffInlineCommentUndoView())->setIsOnRight(false),
       'r' => id(new PHUIDiffInlineCommentUndoView())->setIsOnRight(true),
     );
 
     foreach ($views as $key => $view) {
       $scaffold = $this->getRowScaffoldForInline($view);
 
       $scaffold->setIsUndoTemplate(true);
 
       $views[$key] = id(new PHUIDiffInlineCommentTableScaffold())
         ->addRowScaffold($scaffold);
     }
 
     return $views;
   }
 
   final protected function getScopeEngine() {
     if ($this->scopeEngine === false) {
       $hunk_starts = $this->getHunkStartLines();
 
       // If this change is missing context, don't try to identify scopes, since
       // we won't really be able to get anywhere.
       $has_multiple_hunks = (count($hunk_starts) > 1);
 
       $has_offset_hunks = false;
       if ($hunk_starts) {
         $has_offset_hunks = (head_key($hunk_starts) != 1);
       }
 
       $missing_context = ($has_multiple_hunks || $has_offset_hunks);
 
       if ($missing_context) {
         $scope_engine = null;
       } else {
         $line_map = $this->getNewLineTextMap();
         $scope_engine = id(new PhabricatorDiffScopeEngine())
           ->setLineTextMap($line_map);
       }
 
       $this->scopeEngine = $scope_engine;
     }
 
     return $this->scopeEngine;
   }
 
   private function getNewLineTextMap() {
     $new = $this->getNewLines();
 
     $text_map = array();
     foreach ($new as $new_line) {
       if (!isset($new_line['line'])) {
         continue;
       }
       $text_map[$new_line['line']] = $new_line['text'];
     }
 
     return $text_map;
   }
 
 }
diff --git a/src/applications/differential/storage/DifferentialChangeset.php b/src/applications/differential/storage/DifferentialChangeset.php
index d92b27e574..7b20c22a34 100644
--- a/src/applications/differential/storage/DifferentialChangeset.php
+++ b/src/applications/differential/storage/DifferentialChangeset.php
@@ -1,786 +1,786 @@
 <?php
 
 final class DifferentialChangeset
   extends DifferentialDAO
   implements
     PhabricatorPolicyInterface,
     PhabricatorDestructibleInterface,
     PhabricatorConduitResultInterface {
 
   protected $diffID;
   protected $oldFile;
   protected $filename;
   protected $awayPaths;
   protected $changeType;
   protected $fileType;
   protected $metadata = array();
   protected $oldProperties;
   protected $newProperties;
   protected $addLines;
   protected $delLines;
 
   private $unsavedHunks = array();
   private $hunks = self::ATTACHABLE;
   private $diff = self::ATTACHABLE;
 
   private $authorityPackages;
   private $changesetPackages;
 
   private $newFileObject = self::ATTACHABLE;
   private $oldFileObject = self::ATTACHABLE;
 
   private $hasOldState;
   private $hasNewState;
   private $oldStateMetadata;
   private $newStateMetadata;
   private $oldFileType;
   private $newFileType;
 
   const TABLE_CACHE = 'differential_changeset_parse_cache';
 
   const METADATA_TRUSTED_ATTRIBUTES = 'attributes.trusted';
   const METADATA_UNTRUSTED_ATTRIBUTES = 'attributes.untrusted';
   const METADATA_EFFECT_HASH = 'hash.effect';
 
   const ATTRIBUTE_GENERATED = 'generated';
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_SERIALIZATION => array(
         'metadata'      => self::SERIALIZATION_JSON,
         'oldProperties' => self::SERIALIZATION_JSON,
         'newProperties' => self::SERIALIZATION_JSON,
         'awayPaths'     => self::SERIALIZATION_JSON,
       ),
       self::CONFIG_COLUMN_SCHEMA => array(
         'oldFile' => 'bytes?',
         'filename' => 'bytes',
         'changeType' => 'uint32',
         'fileType' => 'uint32',
         'addLines' => 'uint32',
         'delLines' => 'uint32',
 
         // T6203/NULLABILITY
         // These should all be non-nullable, and store reasonable default
         // JSON values if empty.
         'awayPaths' => 'text?',
         'metadata' => 'text?',
         'oldProperties' => 'text?',
         'newProperties' => 'text?',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'diffID' => array(
           'columns' => array('diffID'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function getPHIDType() {
     return DifferentialChangesetPHIDType::TYPECONST;
   }
 
   public function getAffectedLineCount() {
     return $this->getAddLines() + $this->getDelLines();
   }
 
   public function attachHunks(array $hunks) {
     assert_instances_of($hunks, 'DifferentialHunk');
     $this->hunks = $hunks;
     return $this;
   }
 
   public function getHunks() {
     return $this->assertAttached($this->hunks);
   }
 
   public function getDisplayFilename() {
     $name = $this->getFilename();
     if ($this->getFileType() == DifferentialChangeType::FILE_DIRECTORY) {
       $name .= '/';
     }
     return $name;
   }
 
   public function getOwnersFilename() {
     // TODO: For Subversion, we should adjust these paths to be relative to
     // the repository root where possible.
 
     $path = $this->getFilename();
 
     if (!isset($path[0])) {
       return '/';
     }
 
     if ($path[0] != '/') {
       $path = '/'.$path;
     }
 
     return $path;
   }
 
   public function addUnsavedHunk(DifferentialHunk $hunk) {
     if ($this->hunks === self::ATTACHABLE) {
       $this->hunks = array();
     }
     $this->hunks[] = $hunk;
     $this->unsavedHunks[] = $hunk;
     return $this;
   }
 
   public function setAuthorityPackages(array $authority_packages) {
     $this->authorityPackages = mpull($authority_packages, null, 'getPHID');
     return $this;
   }
 
   public function getAuthorityPackages() {
     return $this->authorityPackages;
   }
 
   public function setChangesetPackages($changeset_packages) {
     $this->changesetPackages = mpull($changeset_packages, null, 'getPHID');
     return $this;
   }
 
   public function getChangesetPackages() {
     return $this->changesetPackages;
   }
 
   public function setHasOldState($has_old_state) {
     $this->hasOldState = $has_old_state;
     return $this;
   }
 
   public function setHasNewState($has_new_state) {
     $this->hasNewState = $has_new_state;
     return $this;
   }
 
   public function hasOldState() {
     if ($this->hasOldState !== null) {
       return $this->hasOldState;
     }
 
     $change_type = $this->getChangeType();
     return !DifferentialChangeType::isCreateChangeType($change_type);
   }
 
   public function hasNewState() {
     if ($this->hasNewState !== null) {
       return $this->hasNewState;
     }
 
     $change_type = $this->getChangeType();
     return !DifferentialChangeType::isDeleteChangeType($change_type);
   }
 
   public function save() {
     $this->openTransaction();
       $ret = parent::save();
       foreach ($this->unsavedHunks as $hunk) {
         $hunk->setChangesetID($this->getID());
         $hunk->save();
       }
     $this->saveTransaction();
     return $ret;
   }
 
   public function delete() {
     $this->openTransaction();
 
       $hunks = id(new DifferentialHunk())->loadAllWhere(
         'changesetID = %d',
         $this->getID());
       foreach ($hunks as $hunk) {
         $hunk->delete();
       }
 
       $this->unsavedHunks = array();
 
       queryfx(
         $this->establishConnection('w'),
         'DELETE FROM %T WHERE id = %d',
         self::TABLE_CACHE,
         $this->getID());
 
       $ret = parent::delete();
     $this->saveTransaction();
     return $ret;
   }
 
   /**
    * Test if this changeset and some other changeset put the affected file in
    * the same state.
    *
-   * @param DifferentialChangeset Changeset to compare against.
+   * @param DifferentialChangeset $other Changeset to compare against.
    * @return bool True if the two changesets have the same effect.
    */
   public function hasSameEffectAs(DifferentialChangeset $other) {
     if ($this->getFilename() !== $other->getFilename()) {
       return false;
     }
 
     $hash_key = self::METADATA_EFFECT_HASH;
 
     $u_hash = $this->getChangesetMetadata($hash_key);
     if ($u_hash === null) {
       return false;
     }
 
     $v_hash = $other->getChangesetMetadata($hash_key);
     if ($v_hash === null) {
       return false;
     }
 
     if ($u_hash !== $v_hash) {
       return false;
     }
 
     // Make sure the final states for the file properties (like the "+x"
     // executable bit) match one another.
     $u_props = $this->getNewProperties();
     $v_props = $other->getNewProperties();
     ksort($u_props);
     ksort($v_props);
 
     if ($u_props !== $v_props) {
       return false;
     }
 
     return true;
   }
 
   public function getSortKey() {
     $sort_key = $this->getFilename();
     // Sort files with ".h" in them first, so headers (.h, .hpp) come before
     // implementations (.c, .cpp, .cs).
     $sort_key = str_replace('.h', '.!h', $sort_key);
     return $sort_key;
   }
 
   public function makeNewFile() {
     $file = mpull($this->getHunks(), 'makeNewFile');
     return implode('', $file);
   }
 
   public function makeOldFile() {
     $file = mpull($this->getHunks(), 'makeOldFile');
     return implode('', $file);
   }
 
   public function makeChangesWithContext($num_lines = 3) {
     $with_context = array();
     foreach ($this->getHunks() as $hunk) {
       $context = array();
       $changes = explode("\n", $hunk->getChanges());
       foreach ($changes as $l => $line) {
         $type = substr($line, 0, 1);
         if ($type == '+' || $type == '-') {
           $context += array_fill($l - $num_lines, 2 * $num_lines + 1, true);
         }
       }
       $with_context[] = array_intersect_key($changes, $context);
     }
     return array_mergev($with_context);
   }
 
   public function getAnchorName() {
     return 'change-'.PhabricatorHash::digestForAnchor($this->getFilename());
   }
 
   public function getAbsoluteRepositoryPath(
     PhabricatorRepository $repository = null,
     DifferentialDiff $diff = null) {
 
     $base = '/';
     if ($diff && $diff->getSourceControlPath()) {
       $base = id(new PhutilURI($diff->getSourceControlPath()))->getPath();
     }
 
     $path = $this->getFilename();
     $path = rtrim($base, '/').'/'.ltrim($path, '/');
 
     $svn = PhabricatorRepositoryType::REPOSITORY_TYPE_SVN;
     if ($repository && $repository->getVersionControlSystem() == $svn) {
       $prefix = $repository->getDetail('remote-uri');
       $prefix = id(new PhutilURI($prefix))->getPath();
       if (!strncmp($path, $prefix, strlen($prefix))) {
         $path = substr($path, strlen($prefix));
       }
       $path = '/'.ltrim($path, '/');
     }
 
     return $path;
   }
 
   public function attachDiff(DifferentialDiff $diff) {
     $this->diff = $diff;
     return $this;
   }
 
   public function getDiff() {
     return $this->assertAttached($this->diff);
   }
 
   public function getOldStatePathVector() {
     $path = $this->getOldFile();
     if (!phutil_nonempty_string($path)) {
       $path = $this->getFilename();
     }
 
     if (!phutil_nonempty_string($path)) {
       return null;
     }
 
     $path = trim($path, '/');
     return explode('/', $path);
   }
 
   public function getNewStatePathVector() {
     if (!$this->hasNewState()) {
       return null;
     }
 
     $path = $this->getFilename();
     $path = trim($path, '/');
     $path = explode('/', $path);
 
     return $path;
   }
 
   public function newFileTreeIcon() {
     $icon = $this->getPathIconIcon();
     $color = $this->getPathIconColor();
 
     return id(new PHUIIconView())
       ->setIcon("{$icon} {$color}");
   }
 
   public function getIsOwnedChangeset() {
     $authority_packages = $this->getAuthorityPackages();
     $changeset_packages = $this->getChangesetPackages();
 
     if (!$authority_packages || !$changeset_packages) {
       return false;
     }
 
     return (bool)array_intersect_key($authority_packages, $changeset_packages);
   }
 
   public function getIsLowImportanceChangeset() {
     if (!$this->hasNewState()) {
       return true;
     }
 
     if ($this->isGeneratedChangeset()) {
       return true;
     }
 
     return false;
   }
 
   public function getPathIconIcon() {
     return idx($this->getPathIconDetails(), 'icon');
   }
 
   public function getPathIconColor() {
     return idx($this->getPathIconDetails(), 'color');
   }
 
   private function getPathIconDetails() {
     $change_icons = array(
       DifferentialChangeType::TYPE_DELETE => array(
         'icon' => 'fa-times',
         'color' => 'delete-color',
       ),
       DifferentialChangeType::TYPE_ADD => array(
         'icon' => 'fa-plus',
         'color' => 'create-color',
       ),
       DifferentialChangeType::TYPE_MOVE_AWAY => array(
         'icon' => 'fa-circle-o',
         'color' => 'grey',
       ),
       DifferentialChangeType::TYPE_MULTICOPY => array(
         'icon' => 'fa-circle-o',
         'color' => 'grey',
       ),
       DifferentialChangeType::TYPE_MOVE_HERE => array(
         'icon' => 'fa-plus-circle',
         'color' => 'create-color',
       ),
       DifferentialChangeType::TYPE_COPY_HERE => array(
         'icon' => 'fa-plus-circle',
         'color' => 'create-color',
       ),
     );
 
     $change_type = $this->getChangeType();
     if (isset($change_icons[$change_type])) {
       return $change_icons[$change_type];
     }
 
     if ($this->isGeneratedChangeset()) {
       return array(
         'icon' => 'fa-cogs',
         'color' => 'grey',
       );
     }
 
     $file_type = $this->getFileType();
     $icon = DifferentialChangeType::getIconForFileType($file_type);
 
     return array(
       'icon' => $icon,
       'color' => 'bluetext',
     );
   }
 
   public function setChangesetMetadata($key, $value) {
     if (!is_array($this->metadata)) {
       $this->metadata = array();
     }
 
     $this->metadata[$key] = $value;
 
     return $this;
   }
 
   public function getChangesetMetadata($key, $default = null) {
     if (!is_array($this->metadata)) {
       return $default;
     }
 
     return idx($this->metadata, $key, $default);
   }
 
   private function setInternalChangesetAttribute($trusted, $key, $value) {
     if ($trusted) {
       $meta_key = self::METADATA_TRUSTED_ATTRIBUTES;
     } else {
       $meta_key = self::METADATA_UNTRUSTED_ATTRIBUTES;
     }
 
     $attributes = $this->getChangesetMetadata($meta_key, array());
     $attributes[$key] = $value;
     $this->setChangesetMetadata($meta_key, $attributes);
 
     return $this;
   }
 
   private function getInternalChangesetAttributes($trusted) {
     if ($trusted) {
       $meta_key = self::METADATA_TRUSTED_ATTRIBUTES;
     } else {
       $meta_key = self::METADATA_UNTRUSTED_ATTRIBUTES;
     }
 
     return $this->getChangesetMetadata($meta_key, array());
   }
 
   public function setTrustedChangesetAttribute($key, $value) {
     return $this->setInternalChangesetAttribute(true, $key, $value);
   }
 
   public function getTrustedChangesetAttributes() {
     return $this->getInternalChangesetAttributes(true);
   }
 
   public function getTrustedChangesetAttribute($key, $default = null) {
     $map = $this->getTrustedChangesetAttributes();
     return idx($map, $key, $default);
   }
 
   public function setUntrustedChangesetAttribute($key, $value) {
     return $this->setInternalChangesetAttribute(false, $key, $value);
   }
 
   public function getUntrustedChangesetAttributes() {
     return $this->getInternalChangesetAttributes(false);
   }
 
   public function getUntrustedChangesetAttribute($key, $default = null) {
     $map = $this->getUntrustedChangesetAttributes();
     return idx($map, $key, $default);
   }
 
   public function getChangesetAttributes() {
     // Prefer trusted values over untrusted values when both exist.
     return
       $this->getTrustedChangesetAttributes() +
       $this->getUntrustedChangesetAttributes();
   }
 
   public function getChangesetAttribute($key, $default = null) {
     $map = $this->getChangesetAttributes();
     return idx($map, $key, $default);
   }
 
   public function isGeneratedChangeset() {
     return $this->getChangesetAttribute(self::ATTRIBUTE_GENERATED);
   }
 
   public function getNewFileObjectPHID() {
     $metadata = $this->getMetadata();
     return idx($metadata, 'new:binary-phid');
   }
 
   public function getOldFileObjectPHID() {
     $metadata = $this->getMetadata();
     return idx($metadata, 'old:binary-phid');
   }
 
   public function attachNewFileObject(PhabricatorFile $file) {
     $this->newFileObject = $file;
     return $this;
   }
 
   public function getNewFileObject() {
     return $this->assertAttached($this->newFileObject);
   }
 
   public function attachOldFileObject(PhabricatorFile $file) {
     $this->oldFileObject = $file;
     return $this;
   }
 
   public function getOldFileObject() {
     return $this->assertAttached($this->oldFileObject);
   }
 
   public function newComparisonChangeset(
     DifferentialChangeset $against = null) {
 
     $left = $this;
     $right = $against;
 
     $left_data = $left->makeNewFile();
     $left_properties = $left->getNewProperties();
     $left_metadata = $left->getNewStateMetadata();
     $left_state = $left->hasNewState();
     $shared_metadata = $left->getMetadata();
     $left_type = $left->getNewFileType();
     if ($right) {
       $right_data = $right->makeNewFile();
       $right_properties = $right->getNewProperties();
       $right_metadata = $right->getNewStateMetadata();
       $right_state = $right->hasNewState();
       $shared_metadata = $right->getMetadata();
       $right_type = $right->getNewFileType();
 
       $file_name = $right->getFilename();
     } else {
       $right_data = $left->makeOldFile();
       $right_properties = $left->getOldProperties();
       $right_metadata = $left->getOldStateMetadata();
       $right_state = $left->hasOldState();
       $right_type = $left->getOldFileType();
 
       $file_name = $left->getFilename();
     }
 
     $engine = new PhabricatorDifferenceEngine();
 
     $synthetic = $engine->generateChangesetFromFileContent(
       $left_data,
       $right_data);
 
     $comparison = id(new self())
       ->makeEphemeral(true)
       ->attachDiff($left->getDiff())
       ->setOldFile($left->getFilename())
       ->setFilename($file_name);
 
     // TODO: Change type?
     // TODO: Away paths?
     // TODO: View state key?
 
     $comparison->attachHunks($synthetic->getHunks());
 
     $comparison->setOldProperties($left_properties);
     $comparison->setNewProperties($right_properties);
 
     $comparison
       ->setOldStateMetadata($left_metadata)
       ->setNewStateMetadata($right_metadata)
       ->setHasOldState($left_state)
       ->setHasNewState($right_state)
       ->setOldFileType($left_type)
       ->setNewFileType($right_type);
 
     // NOTE: Some metadata is not stored statefully, like the "generated"
     // flag. For now, use the rightmost "new state" metadata to fill in these
     // values.
 
     $metadata = $comparison->getMetadata();
     $metadata = $metadata + $shared_metadata;
     $comparison->setMetadata($metadata);
 
     return $comparison;
   }
 
 
   public function setNewFileType($new_file_type) {
     $this->newFileType = $new_file_type;
     return $this;
   }
 
   public function getNewFileType() {
     if ($this->newFileType !== null) {
       return $this->newFileType;
     }
 
     return $this->getFiletype();
   }
 
   public function setOldFileType($old_file_type) {
     $this->oldFileType = $old_file_type;
     return $this;
   }
 
   public function getOldFileType() {
     if ($this->oldFileType !== null) {
       return $this->oldFileType;
     }
 
     return $this->getFileType();
   }
 
   public function hasSourceTextBody() {
     $type_map = array(
       DifferentialChangeType::FILE_TEXT => true,
       DifferentialChangeType::FILE_SYMLINK => true,
     );
 
     $old_body = isset($type_map[$this->getOldFileType()]);
     $new_body = isset($type_map[$this->getNewFileType()]);
 
     return ($old_body || $new_body);
   }
 
   public function getNewStateMetadata() {
     return $this->getMetadataWithPrefix('new:');
   }
 
   public function setNewStateMetadata(array $metadata) {
     return $this->setMetadataWithPrefix($metadata, 'new:');
   }
 
   public function getOldStateMetadata() {
     return $this->getMetadataWithPrefix('old:');
   }
 
   public function setOldStateMetadata(array $metadata) {
     return $this->setMetadataWithPrefix($metadata, 'old:');
   }
 
   private function getMetadataWithPrefix($prefix) {
     $length = strlen($prefix);
 
     $result = array();
     foreach ($this->getMetadata() as $key => $value) {
       if (strncmp($key, $prefix, $length)) {
         continue;
       }
 
       $key = substr($key, $length);
       $result[$key] = $value;
     }
 
     return $result;
   }
 
   private function setMetadataWithPrefix(array $metadata, $prefix) {
     foreach ($metadata as $key => $value) {
       $key = $prefix.$key;
       $this->metadata[$key] = $value;
     }
 
     return $this;
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
     );
   }
 
   public function getPolicy($capability) {
     return $this->getDiff()->getPolicy($capability);
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return $this->getDiff()->hasAutomaticCapability($capability, $viewer);
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
     $this->openTransaction();
 
       $hunks = id(new DifferentialHunk())->loadAllWhere(
         'changesetID = %d',
         $this->getID());
       foreach ($hunks as $hunk) {
         $engine->destroyObject($hunk);
       }
 
       $this->delete();
 
     $this->saveTransaction();
   }
 
 /* -(  PhabricatorConduitResultInterface  )---------------------------------- */
 
   public function getFieldSpecificationsForConduit() {
     return array(
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('diffPHID')
         ->setType('phid')
         ->setDescription(pht('The diff the changeset is attached to.')),
     );
   }
 
   public function getFieldValuesForConduit() {
     $diff = $this->getDiff();
 
     $repository = null;
     if ($diff) {
       $revision = $diff->getRevision();
       if ($revision) {
         $repository = $revision->getRepository();
       }
     }
 
     $absolute_path = $this->getAbsoluteRepositoryPath($repository, $diff);
     if (strlen($absolute_path)) {
       $absolute_path = base64_encode($absolute_path);
     } else {
       $absolute_path = null;
     }
 
     $display_path = $this->getDisplayFilename();
 
     return array(
       'diffPHID' => $diff->getPHID(),
       'path' => array(
         'displayPath' => $display_path,
         'absolutePath.base64' => $absolute_path,
       ),
     );
   }
 
   public function getConduitSearchAttachments() {
     return array();
   }
 
 
 }
diff --git a/src/applications/diffusion/data/DiffusionBrowseResultSet.php b/src/applications/diffusion/data/DiffusionBrowseResultSet.php
index 22d9e08251..f112ab49ed 100644
--- a/src/applications/diffusion/data/DiffusionBrowseResultSet.php
+++ b/src/applications/diffusion/data/DiffusionBrowseResultSet.php
@@ -1,163 +1,163 @@
 <?php
 
 final class DiffusionBrowseResultSet extends Phobject {
 
   const REASON_IS_FILE              = 'is-file';
   const REASON_IS_SUBMODULE         = 'is-submodule';
   const REASON_IS_DELETED           = 'is-deleted';
   const REASON_IS_NONEXISTENT       = 'nonexistent';
   const REASON_BAD_COMMIT           = 'bad-commit';
   const REASON_IS_EMPTY             = 'empty';
   const REASON_IS_UNTRACKED_PARENT  = 'untracked-parent';
 
   private $paths;
   private $isValidResults;
   private $reasonForEmptyResultSet;
   private $existedAtCommit;
   private $deletedAtCommit;
 
   public function setPaths(array $paths) {
     assert_instances_of($paths, 'DiffusionRepositoryPath');
     $this->paths = $paths;
     return $this;
   }
   public function getPaths() {
     return $this->paths;
   }
 
   public function setIsValidResults($is_valid) {
     $this->isValidResults = $is_valid;
     return $this;
   }
   public function isValidResults() {
     return $this->isValidResults;
   }
 
   public function setReasonForEmptyResultSet($reason) {
     $this->reasonForEmptyResultSet = $reason;
     return $this;
   }
   public function getReasonForEmptyResultSet() {
     return $this->reasonForEmptyResultSet;
   }
 
   public function setExistedAtCommit($existed_at_commit) {
     $this->existedAtCommit = $existed_at_commit;
     return $this;
   }
   public function getExistedAtCommit() {
     return $this->existedAtCommit;
   }
 
   public function setDeletedAtCommit($deleted_at_commit) {
     $this->deletedAtCommit = $deleted_at_commit;
     return $this;
   }
   public function getDeletedAtCommit() {
     return $this->deletedAtCommit;
   }
 
   public function toDictionary() {
     $paths = $this->getPathDicts();
 
     return array(
       'paths' => $paths,
       'isValidResults' => $this->isValidResults(),
       'reasonForEmptyResultSet' => $this->getReasonForEmptyResultSet(),
       'existedAtCommit' => $this->getExistedAtCommit(),
       'deletedAtCommit' => $this->getDeletedAtCommit(),
     );
   }
 
   public function getPathDicts() {
     $paths = $this->getPaths();
     if ($paths) {
       return mpull($paths, 'toDictionary');
     }
     return array();
   }
 
   /**
    * Get the best README file in this result set, if one exists.
    *
    * Callers should normally use `diffusion.filecontentquery` to pull README
    * content.
    *
    * @return string|null Full path to best README, or null if one does not
    *   exist.
    */
   public function getReadmePath() {
     $allowed_types = array(
       ArcanistDiffChangeType::FILE_NORMAL => true,
       ArcanistDiffChangeType::FILE_TEXT => true,
     );
 
     $candidates = array();
     foreach ($this->getPaths() as $path_object) {
       if (empty($allowed_types[$path_object->getFileType()])) {
         // Skip directories, images, etc.
         continue;
       }
 
       $local_path = $path_object->getPath();
       if (!preg_match('/^readme(\.|$)/i', $local_path)) {
         // Skip files not named "README".
         continue;
       }
 
       $full_path = $path_object->getFullPath();
       $candidates[$full_path] = self::getReadmePriority($local_path);
     }
 
     if (!$candidates) {
       return null;
     }
 
     arsort($candidates);
     return head_key($candidates);
   }
 
   /**
    * Get the priority of a README file.
    *
    * When a directory contains several README files, this function scores them
    * so the caller can select a preferred file. See @{method:getReadmePath}.
    *
-   * @param string Local README path, like "README.txt".
+   * @param string $path Local README path, like "README.txt".
    * @return int Priority score, with higher being more preferred.
    */
   public static function getReadmePriority($path) {
     $path = phutil_utf8_strtolower($path);
     if ($path == 'readme') {
       return 90;
     }
 
     $ext = last(explode('.', $path));
     switch ($ext) {
       case 'remarkup':
         return 100;
       case 'rainbow':
         return 80;
       case 'md':
         return 70;
       case 'txt':
         return 60;
       default:
         return 50;
     }
   }
 
   public static function newFromConduit(array $data) {
     $paths = array();
     $path_dicts = $data['paths'];
     foreach ($path_dicts as $dict) {
       $paths[] = DiffusionRepositoryPath::newFromDictionary($dict);
     }
     return id(new DiffusionBrowseResultSet())
       ->setPaths($paths)
       ->setIsValidResults($data['isValidResults'])
       ->setReasonForEmptyResultSet($data['reasonForEmptyResultSet'])
       ->setExistedAtCommit($data['existedAtCommit'])
       ->setDeletedAtCommit($data['deletedAtCommit']);
   }
 }
diff --git a/src/applications/diffusion/data/DiffusionGitBranch.php b/src/applications/diffusion/data/DiffusionGitBranch.php
index 7fe8f5c6c0..75c17b67c1 100644
--- a/src/applications/diffusion/data/DiffusionGitBranch.php
+++ b/src/applications/diffusion/data/DiffusionGitBranch.php
@@ -1,110 +1,111 @@
 <?php
 
 final class DiffusionGitBranch extends Phobject {
 
   const DEFAULT_GIT_REMOTE = 'origin';
 
   /**
    * Parse the output of 'git branch -r --verbose --no-abbrev' or similar into
    * a map. For instance:
    *
    *   array(
    *     'origin/master' => '99a9c082f9a1b68c7264e26b9e552484a5ae5f25',
    *   );
    *
    * If you specify $only_this_remote, branches will be filtered to only those
    * on the given remote, **and the remote name will be stripped**. For example:
    *
    *   array(
    *     'master' => '99a9c082f9a1b68c7264e26b9e552484a5ae5f25',
    *   );
    *
-   * @param string stdout of git branch command.
-   * @param string Filter branches to those on a specific remote.
+   * @param string $stdout stdout of git branch command.
+   * @param string? $only_this_remote Filter branches to those on a specific
+   *   remote.
    * @return map Map of 'branch' or 'remote/branch' to hash at HEAD.
    */
   public static function parseRemoteBranchOutput(
     $stdout,
     $only_this_remote = null) {
     $map = array();
 
     $lines = array_filter(explode("\n", $stdout));
     foreach ($lines as $line) {
       $matches = null;
       if (preg_match('/^  (\S+)\s+-> (\S+)$/', $line, $matches)) {
           // This is a line like:
           //
           //   origin/HEAD          -> origin/master
           //
           // ...which we don't currently do anything interesting with, although
           // in theory we could use it to automatically choose the default
           // branch.
           continue;
       }
       if (!preg_match('/^ *(\S+)\s+([a-z0-9]{40})/', $line, $matches)) {
         throw new Exception(
           pht(
             'Failed to parse %s!',
             $line));
       }
 
       $remote_branch = $matches[1];
       $branch_head = $matches[2];
 
       if (strpos($remote_branch, 'HEAD') !== false) {
         // let's assume that no one will call their remote or branch HEAD
         continue;
       }
 
       if ($only_this_remote) {
         $matches = null;
         if (!preg_match('#^([^/]+)/(.*)$#', $remote_branch, $matches)) {
           throw new Exception(
             pht(
               "Failed to parse remote branch '%s'!",
               $remote_branch));
         }
         $remote_name = $matches[1];
         $branch_name = $matches[2];
         if ($remote_name != $only_this_remote) {
           continue;
         }
         $map[$branch_name] = $branch_head;
       } else {
         $map[$remote_branch] = $branch_head;
       }
     }
 
     return $map;
   }
 
   /**
    * As above, but with no `-r`. Used for bare repositories.
    */
   public static function parseLocalBranchOutput($stdout) {
     $map = array();
 
     $lines = array_filter(explode("\n", $stdout));
     $regex = '/^[* ]*(\(no branch\)|\S+)\s+([a-z0-9]{40})/';
     foreach ($lines as $line) {
       $matches = null;
       if (!preg_match($regex, $line, $matches)) {
         throw new Exception(
           pht(
             'Failed to parse %s!',
             $line));
       }
 
       $branch = $matches[1];
       $branch_head = $matches[2];
       if ($branch == '(no branch)') {
         continue;
       }
 
       $map[$branch] = $branch_head;
     }
 
     return $map;
   }
 
 }
diff --git a/src/applications/diffusion/protocol/DiffusionMercurialCommandEngine.php b/src/applications/diffusion/protocol/DiffusionMercurialCommandEngine.php
index dc898f81d4..ee70ce217d 100644
--- a/src/applications/diffusion/protocol/DiffusionMercurialCommandEngine.php
+++ b/src/applications/diffusion/protocol/DiffusionMercurialCommandEngine.php
@@ -1,122 +1,122 @@
 <?php
 
 final class DiffusionMercurialCommandEngine
   extends DiffusionCommandEngine {
 
   protected function canBuildForRepository(
     PhabricatorRepository $repository) {
     return $repository->isHg();
   }
 
   protected function newFormattedCommand($pattern, array $argv) {
     $args = array();
 
     // Crudely blacklist commands which look like they may contain command
     // injection via "--config" or "--debugger". See T13012. To do this, we
     // print the whole command, parse it using shell rules, then examine each
     // argument to see if it looks like "--config" or "--debugger".
 
     $test_command = call_user_func_array(
       'csprintf',
       array_merge(array($pattern), $argv));
     $test_args = id(new PhutilShellLexer())
       ->splitArguments($test_command);
 
     foreach ($test_args as $test_arg) {
       if (preg_match('/^--(config|debugger)/i', $test_arg)) {
         throw new DiffusionMercurialFlagInjectionException(
           pht(
             'Mercurial command appears to contain unsafe injected "--config" '.
             'or "--debugger": %s',
             $test_command));
       }
     }
 
     // NOTE: Here, and in Git and Subversion, we override the SSH command even
     // if the repository does not use an SSH remote, since our SSH wrapper
     // defuses an attack against older versions of Mercurial, Git and
     // Subversion (see T12961) and it's possible to execute this attack
     // in indirect ways, like by using an SSH subrepo inside an HTTP repo.
 
     $pattern = "hg --config ui.ssh=%s {$pattern}";
     $args[] = $this->getSSHWrapper();
 
     return array($pattern, array_merge($args, $argv));
   }
 
   protected function newCustomEnvironment() {
     $env = array();
 
     // NOTE: This overrides certain configuration, extensions, and settings
     // which make Mercurial commands do random unusual things.
     $env['HGPLAIN'] = 1;
 
     return $env;
   }
 
   /**
    * Sanitize output of an `hg` command invoked with the `--debug` flag to make
    * it usable.
    *
-   * @param string Output from `hg --debug ...`
+   * @param string $stdout Output from `hg --debug ...`
    * @return string Usable output.
    */
   public static function filterMercurialDebugOutput($stdout) {
     // When hg commands are run with `--debug` and some config file isn't
     // trusted, Mercurial prints out a warning to stdout, twice, after Feb 2011.
     //
     // http://selenic.com/pipermail/mercurial-devel/2011-February/028541.html
     //
     // After Jan 2015, it may also fail to write to a revision branch cache.
     //
     // Separately, it may fail to write to a different branch cache, and may
     // encounter issues reading the branch cache.
     //
     // When Mercurial repositories are hosted on external systems with
     // multi-user environments it's possible that the branch cache is computed
     // on a revision which does not end up being published. When this happens it
     // will recompute the cache but also print out "invalid branch cache".
     //
     // https://www.mercurial-scm.org/pipermail/mercurial/2014-June/047239.html
     //
     // When observing a repository which uses largefiles, the debug output may
     // also contain extraneous output about largefile changes.
     //
     // At some point Mercurial added/improved support for pager used when
     // command output is large. It includes printing out debug information that
     // the pager is being started for a command. This seems to happen despite
     // the output of the command being piped/read from another process.
     //
     // When printing color output Mercurial may run into some issue with the
     // terminal info. This should never happen in Phabricator since color
     // output should be turned off, however in the event it shows up we should
     // filter it out anyways.
 
     $ignore = array(
       'ignoring untrusted configuration option',
       "couldn't write revision branch cache:",
       "couldn't write branch cache:",
       'invalid branchheads cache',
       'invalid branch cache',
       'updated patterns: .hglf',
       'starting pager for command',
       'no terminfo entry for',
     );
 
     foreach ($ignore as $key => $pattern) {
       $ignore[$key] = preg_quote($pattern, '/');
     }
 
     $ignore = '('.implode('|', $ignore).')';
 
     $lines = preg_split('/(?<=\n)/', $stdout);
     $regex = '/'.$ignore.'.*\n$/';
 
     foreach ($lines as $key => $line) {
       $lines[$key] = preg_replace($regex, '', $line);
     }
 
     return implode('', $lines);
   }
 
 }
diff --git a/src/applications/diffusion/query/pathid/DiffusionPathIDQuery.php b/src/applications/diffusion/query/pathid/DiffusionPathIDQuery.php
index d994410034..69bb89502a 100644
--- a/src/applications/diffusion/query/pathid/DiffusionPathIDQuery.php
+++ b/src/applications/diffusion/query/pathid/DiffusionPathIDQuery.php
@@ -1,99 +1,99 @@
 <?php
 
 /**
  * @task pathutil Path Utilities
  */
 final class DiffusionPathIDQuery extends Phobject {
 
   private $paths = array();
 
   public function __construct(array $paths) {
     $this->paths = $paths;
   }
 
   public function loadPathIDs() {
     $repository = new PhabricatorRepository();
 
     $path_normal_map = array();
     foreach ($this->paths as $path) {
       $normal = self::normalizePath($path);
       $path_normal_map[$normal][] = $path;
     }
 
     $paths = queryfx_all(
       $repository->establishConnection('r'),
       'SELECT * FROM %T WHERE pathHash IN (%Ls)',
       PhabricatorRepository::TABLE_PATH,
       array_map('md5', array_keys($path_normal_map)));
     $paths = ipull($paths, 'id', 'path');
 
     $result = array();
 
     foreach ($path_normal_map as $normal => $originals) {
       foreach ($originals as $original) {
         $result[$original] = idx($paths, $normal);
       }
     }
 
     return $result;
   }
 
 
   /**
    * Convert a path to the canonical, absolute representation used by Diffusion.
    *
-   * @param string Some repository path.
+   * @param string $path Some repository path.
    * @return string Canonicalized Diffusion path.
    * @task pathutil
    */
   public static function normalizePath($path) {
 
     // Ensure we have a string, not a null.
     $path = coalesce($path, '');
 
     // Normalize to single slashes, e.g. "///" => "/".
     $path = preg_replace('@[/]{2,}@', '/', $path);
 
     return '/'.trim($path, '/');
   }
 
 
   /**
    * Return the canonical parent directory for a path. Note, returns "/" when
    * passed "/".
    *
-   * @param string Some repository path.
+   * @param string $path Some repository path.
    * @return string That path's canonical parent directory.
    * @task pathutil
    */
   public static function getParentPath($path) {
     $path = self::normalizePath($path);
     $path = dirname($path);
     if (phutil_is_windows() && $path == '\\') {
         $path = '/';
     }
     return $path;
   }
 
 
   /**
    * Generate a list of parents for a repository path. The path itself is
    * included.
    *
-   * @param string Some repository path.
+   * @param string $path Some repository path.
    * @return list List of canonical paths between the path and the root.
    * @task pathutil
    */
   public static function expandPathToRoot($path) {
     $path = self::normalizePath($path);
     $parents = array($path);
     $parts = explode('/', trim($path, '/'));
     while (count($parts) >= 1) {
       if (array_pop($parts)) {
         $parents[] = '/'.implode('/', $parts);
       }
     }
     return $parents;
   }
 
 }
diff --git a/src/applications/diffusion/request/DiffusionRequest.php b/src/applications/diffusion/request/DiffusionRequest.php
index c301b89b28..d9bd30afea 100644
--- a/src/applications/diffusion/request/DiffusionRequest.php
+++ b/src/applications/diffusion/request/DiffusionRequest.php
@@ -1,700 +1,701 @@
 <?php
 
 /**
  * Contains logic to parse Diffusion requests, which have a complicated URI
  * structure.
  *
  * @task new Creating Requests
  * @task uri Managing Diffusion URIs
  */
 abstract class DiffusionRequest extends Phobject {
 
   protected $path;
   protected $line;
   protected $branch;
   protected $lint;
 
   protected $symbolicCommit;
   protected $symbolicType;
   protected $stableCommit;
 
   protected $repository;
   protected $repositoryCommit;
   protected $repositoryCommitData;
 
   private $isClusterRequest = false;
   private $initFromConduit = true;
   private $user;
   private $branchObject = false;
   private $refAlternatives;
 
   final public function supportsBranches() {
     return $this->getRepository()->supportsRefs();
   }
 
   abstract protected function isStableCommit($symbol);
 
   protected function didInitialize() {
     return null;
   }
 
 
 /* -(  Creating Requests  )-------------------------------------------------- */
 
 
   /**
    * Create a new synthetic request from a parameter dictionary. If you need
    * a @{class:DiffusionRequest} object in order to issue a DiffusionQuery, you
    * can use this method to build one.
    *
    * Parameters are:
    *
    *   - `repository` Repository object or identifier.
    *   - `user` Viewing user. Required if `repository` is an identifier.
    *   - `branch` Optional, branch name.
    *   - `path` Optional, file path.
    *   - `commit` Optional, commit identifier.
    *   - `line` Optional, line range.
    *
-   * @param   map                 See documentation.
+   * @param   map                 $data See documentation.
    * @return  DiffusionRequest    New request object.
    * @task new
    */
   final public static function newFromDictionary(array $data) {
     $repository_key = 'repository';
     $identifier_key = 'callsign';
     $viewer_key = 'user';
 
     $repository = idx($data, $repository_key);
     $identifier = idx($data, $identifier_key);
 
     $have_repository = ($repository !== null);
     $have_identifier = ($identifier !== null);
 
     if ($have_repository && $have_identifier) {
       throw new Exception(
         pht(
           'Specify "%s" or "%s", but not both.',
           $repository_key,
           $identifier_key));
     }
 
     if (!$have_repository && !$have_identifier) {
       throw new Exception(
         pht(
           'One of "%s" and "%s" is required.',
           $repository_key,
           $identifier_key));
     }
 
     if ($have_repository) {
       if (!($repository instanceof PhabricatorRepository)) {
         if (empty($data[$viewer_key])) {
           throw new Exception(
             pht(
               'Parameter "%s" is required if "%s" is provided.',
               $viewer_key,
               $identifier_key));
         }
 
         $identifier = $repository;
         $repository = null;
       }
     }
 
     if ($identifier !== null) {
       $object = self::newFromIdentifier(
         $identifier,
         $data[$viewer_key],
         idx($data, 'edit'));
     } else {
       $object = self::newFromRepository($repository);
     }
 
     if (!$object) {
       return null;
     }
 
     $object->initializeFromDictionary($data);
 
     return $object;
   }
 
   /**
    * Internal.
    *
    * @task new
    */
   private function __construct() {
     // <private>
   }
 
 
   /**
    * Internal. Use @{method:newFromDictionary}, not this method.
    *
-   * @param   string              Repository identifier.
-   * @param   PhabricatorUser     Viewing user.
+   * @param   string            $identifier  Repository identifier.
+   * @param   PhabricatorUser   $viewer  Viewing user.
+   * @param   bool?             $need_edit
    * @return  DiffusionRequest    New request object.
    * @task new
    */
   private static function newFromIdentifier(
     $identifier,
     PhabricatorUser $viewer,
     $need_edit = false) {
 
     $query = id(new PhabricatorRepositoryQuery())
       ->setViewer($viewer)
       ->withIdentifiers(array($identifier))
       ->needProfileImage(true)
       ->needURIs(true);
 
     if ($need_edit) {
       $query->requireCapabilities(
         array(
           PhabricatorPolicyCapability::CAN_VIEW,
           PhabricatorPolicyCapability::CAN_EDIT,
         ));
     }
 
     $repository = $query->executeOne();
 
     if (!$repository) {
       return null;
     }
 
     return self::newFromRepository($repository);
   }
 
 
   /**
    * Internal. Use @{method:newFromDictionary}, not this method.
    *
-   * @param   PhabricatorRepository   Repository object.
+   * @param   PhabricatorRepository $repository Repository object.
    * @return  DiffusionRequest        New request object.
    * @task new
    */
   private static function newFromRepository(
     PhabricatorRepository $repository) {
 
     $map = array(
       PhabricatorRepositoryType::REPOSITORY_TYPE_GIT => 'DiffusionGitRequest',
       PhabricatorRepositoryType::REPOSITORY_TYPE_SVN => 'DiffusionSvnRequest',
       PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL =>
         'DiffusionMercurialRequest',
     );
 
     $class = idx($map, $repository->getVersionControlSystem());
 
     if (!$class) {
       throw new Exception(pht('Unknown version control system!'));
     }
 
     $object = new $class();
 
     $object->repository = $repository;
 
     return $object;
   }
 
 
   /**
    * Internal. Use @{method:newFromDictionary}, not this method.
    *
-   * @param map Map of parsed data.
+   * @param map $data Map of parsed data.
    * @return void
    * @task new
    */
   private function initializeFromDictionary(array $data) {
     $blob = idx($data, 'blob');
     if (phutil_nonempty_string($blob)) {
       $blob = self::parseRequestBlob($blob, $this->supportsBranches());
       $data = $blob + $data;
     }
 
     $this->path = idx($data, 'path');
     $this->line = idx($data, 'line');
     $this->initFromConduit = idx($data, 'initFromConduit', true);
     $this->lint = idx($data, 'lint');
 
     $this->symbolicCommit = idx($data, 'commit');
     if ($this->supportsBranches()) {
       $this->branch = idx($data, 'branch');
     }
 
     if (!$this->getUser()) {
       $user = idx($data, 'user');
       if (!$user) {
         throw new Exception(
           pht(
             'You must provide a %s in the dictionary!',
             'PhabricatorUser'));
       }
       $this->setUser($user);
     }
 
     $this->didInitialize();
   }
 
   final public function setUser(PhabricatorUser $user) {
     $this->user = $user;
     return $this;
   }
   final public function getUser() {
     return $this->user;
   }
 
   public function getRepository() {
     return $this->repository;
   }
 
   public function setPath($path) {
     $this->path = $path;
     return $this;
   }
 
   public function getPath() {
     return coalesce($this->path, '');
   }
 
   public function getLine() {
     return $this->line;
   }
 
   public function getCommit() {
 
     // TODO: Probably remove all of this.
 
     if ($this->getSymbolicCommit() !== null) {
       return $this->getSymbolicCommit();
     }
 
     return $this->getStableCommit();
   }
 
   /**
    * Get the symbolic commit associated with this request.
    *
    * A symbolic commit may be a commit hash, an abbreviated commit hash, a
    * branch name, a tag name, or an expression like "HEAD^^^". The symbolic
    * commit may also be absent.
    *
    * This method always returns the symbol present in the original request,
    * in unmodified form.
    *
    * See also @{method:getStableCommit}.
    *
    * @return string|null  Symbolic commit, if one was present in the request.
    */
   public function getSymbolicCommit() {
     return $this->symbolicCommit;
   }
 
 
   /**
    * Modify the request to move the symbolic commit elsewhere.
    *
-   * @param string New symbolic commit.
+   * @param string $symbol New symbolic commit.
    * @return this
    */
   public function updateSymbolicCommit($symbol) {
     $this->symbolicCommit = $symbol;
     $this->symbolicType = null;
     $this->stableCommit = null;
     return $this;
   }
 
 
   /**
    * Get the ref type (`commit` or `tag`) of the location associated with this
    * request.
    *
    * If a symbolic commit is present in the request, this method identifies
    * the type of the symbol. Otherwise, it identifies the type of symbol of
    * the location the request is implicitly associated with. This will probably
    * always be `commit`.
    *
    * @return string   Symbolic commit type (`commit` or `tag`).
    */
   public function getSymbolicType() {
     if ($this->symbolicType === null) {
       // As a side effect, this resolves the symbolic type.
       $this->getStableCommit();
     }
     return $this->symbolicType;
   }
 
 
   /**
    * Retrieve the stable, permanent commit name identifying the repository
    * location associated with this request.
    *
    * This returns a non-symbolic identifier for the current commit: in Git and
    * Mercurial, a 40-character SHA1; in SVN, a revision number.
    *
    * See also @{method:getSymbolicCommit}.
    *
    * @return string Stable commit name, like a git hash or SVN revision. Not
    *                a symbolic commit reference.
    */
   public function getStableCommit() {
     if (!$this->stableCommit) {
       if ($this->isStableCommit($this->symbolicCommit)) {
         $this->stableCommit = $this->symbolicCommit;
         $this->symbolicType = 'commit';
       } else {
         $this->queryStableCommit();
       }
     }
     return $this->stableCommit;
   }
 
 
   public function getBranch() {
     return $this->branch;
   }
 
   public function getLint() {
     return $this->lint;
   }
 
   protected function getArcanistBranch() {
     return $this->getBranch();
   }
 
   public function loadBranch() {
     // TODO: Get rid of this and do real Queries on real objects.
 
     if ($this->branchObject === false) {
       $this->branchObject = PhabricatorRepositoryBranch::loadBranch(
         $this->getRepository()->getID(),
         $this->getArcanistBranch());
     }
 
     return $this->branchObject;
   }
 
   public function loadCoverage() {
     // TODO: This should also die.
     $branch = $this->loadBranch();
     if (!$branch) {
       return;
     }
 
     $path = $this->getPath();
     $path_map = id(new DiffusionPathIDQuery(array($path)))->loadPathIDs();
 
     $coverage_row = queryfx_one(
       id(new PhabricatorRepository())->establishConnection('r'),
       'SELECT * FROM %T WHERE branchID = %d AND pathID = %d
         ORDER BY commitID DESC LIMIT 1',
       'repository_coverage',
       $branch->getID(),
       $path_map[$path]);
 
     if (!$coverage_row) {
       return null;
     }
 
     return idx($coverage_row, 'coverage');
   }
 
 
   public function loadCommit() {
     if (empty($this->repositoryCommit)) {
       $repository = $this->getRepository();
 
       $commit = id(new DiffusionCommitQuery())
         ->setViewer($this->getUser())
         ->withRepository($repository)
         ->withIdentifiers(array($this->getStableCommit()))
         ->executeOne();
       if ($commit) {
         $commit->attachRepository($repository);
       }
       $this->repositoryCommit = $commit;
     }
     return $this->repositoryCommit;
   }
 
   public function loadCommitData() {
     if (empty($this->repositoryCommitData)) {
       $commit = $this->loadCommit();
       $data = id(new PhabricatorRepositoryCommitData())->loadOneWhere(
         'commitID = %d',
         $commit->getID());
       if (!$data) {
         $data = new PhabricatorRepositoryCommitData();
         $data->setCommitMessage(
           pht('(This commit has not been fully parsed yet.)'));
       }
       $this->repositoryCommitData = $data;
     }
     return $this->repositoryCommitData;
   }
 
 /* -(  Managing Diffusion URIs  )-------------------------------------------- */
 
 
   public function generateURI(array $params) {
     if (empty($params['stable'])) {
       $default_commit = $this->getSymbolicCommit();
     } else {
       $default_commit = $this->getStableCommit();
     }
 
     $defaults = array(
       'path'      => $this->getPath(),
       'branch'    => $this->getBranch(),
       'commit'    => $default_commit,
       'lint'      => idx($params, 'lint', $this->getLint()),
     );
 
     foreach ($defaults as $key => $val) {
       if (!isset($params[$key])) { // Overwrite NULL.
         $params[$key] = $val;
       }
     }
 
     return $this->getRepository()->generateURI($params);
   }
 
   /**
    * Internal. Public only for unit tests.
    *
    * Parse the request URI into components.
    *
-   * @param   string  URI blob.
-   * @param   bool    True if this VCS supports branches.
+   * @param   string  $blob URI blob.
+   * @param   bool    $supports_branches True if this VCS supports branches.
    * @return  map     Parsed URI.
    *
    * @task uri
    */
   public static function parseRequestBlob($blob, $supports_branches) {
     $result = array(
       'branch'  => null,
       'path'    => null,
       'commit'  => null,
       'line'    => null,
     );
 
     $matches = null;
 
     if ($supports_branches) {
       // Consume the front part of the URI, up to the first "/". This is the
       // path-component encoded branch name.
       if (preg_match('@^([^/]+)/@', $blob, $matches)) {
         $result['branch'] = phutil_unescape_uri_path_component($matches[1]);
         $blob = substr($blob, strlen($matches[1]) + 1);
       }
     }
 
     // Consume the back part of the URI, up to the first "$". Use a negative
     // lookbehind to prevent matching '$$'. We double the '$' symbol when
     // encoding so that files with names like "money/$100" will survive.
     $pattern = '@(?:(?:^|[^$])(?:[$][$])*)[$]([\d,-]+)$@';
     if (preg_match($pattern, $blob, $matches)) {
       $result['line'] = $matches[1];
       $blob = substr($blob, 0, -(strlen($matches[1]) + 1));
     }
 
     // We've consumed the line number if it exists, so unescape "$" in the
     // rest of the string.
     $blob = str_replace('$$', '$', $blob);
 
     // Consume the commit name, stopping on ';;'. We allow any character to
     // appear in commits names, as they can sometimes be symbolic names (like
     // tag names or refs).
     if (preg_match('@(?:(?:^|[^;])(?:;;)*);([^;].*)$@', $blob, $matches)) {
       $result['commit'] = $matches[1];
       $blob = substr($blob, 0, -(strlen($matches[1]) + 1));
     }
 
     // We've consumed the commit if it exists, so unescape ";" in the rest
     // of the string.
     $blob = str_replace(';;', ';', $blob);
 
     if (strlen($blob)) {
       $result['path'] = $blob;
     }
 
     if ($result['path'] !== null) {
       $parts = explode('/', $result['path']);
       foreach ($parts as $part) {
         // Prevent any hyjinx since we're ultimately shipping this to the
         // filesystem under a lot of workflows.
         if ($part == '..') {
           throw new Exception(pht('Invalid path URI.'));
         }
       }
     }
 
     return $result;
   }
 
   /**
    * Check that the working copy of the repository is present and readable.
    *
-   * @param   string  Path to the working copy.
+   * @param   string $path Path to the working copy.
    */
   protected function validateWorkingCopy($path) {
     if (!is_readable(dirname($path))) {
       $this->raisePermissionException();
     }
 
     if (!Filesystem::pathExists($path)) {
       $this->raiseCloneException();
     }
   }
 
   protected function raisePermissionException() {
     $host = php_uname('n');
     throw new DiffusionSetupException(
       pht(
         'The clone of this repository ("%s") on the local machine ("%s") '.
         'could not be read. Ensure that the repository is in a '.
         'location where the web server has read permissions.',
         $this->getRepository()->getDisplayName(),
         $host));
   }
 
   protected function raiseCloneException() {
     $host = php_uname('n');
     throw new DiffusionSetupException(
       pht(
         'The working copy for this repository ("%s") has not been cloned yet '.
         'on this machine ("%s"). Make sure you have started the '.
         'daemons. If this problem persists for longer than a clone should '.
         'take, check the daemon logs (in the Daemon Console) to see if there '.
         'were errors cloning the repository. Consult the "Diffusion User '.
         'Guide" in the documentation for help setting up repositories.',
         $this->getRepository()->getDisplayName(),
         $host));
   }
 
   private function queryStableCommit() {
     $types = array();
     if ($this->symbolicCommit) {
       $ref = $this->symbolicCommit;
     } else {
       if ($this->supportsBranches()) {
         $ref = $this->getBranch();
         $types = array(
           PhabricatorRepositoryRefCursor::TYPE_BRANCH,
         );
       } else {
         $ref = 'HEAD';
       }
     }
 
     $results = $this->resolveRefs(array($ref), $types);
 
     $matches = idx($results, $ref, array());
     if (!$matches) {
       $message = pht(
         'Ref "%s" does not exist in this repository.',
         $ref);
       throw id(new DiffusionRefNotFoundException($message))
         ->setRef($ref);
     }
 
     if (count($matches) > 1) {
       $match = $this->chooseBestRefMatch($ref, $matches);
     } else {
       $match = head($matches);
     }
 
     $this->stableCommit = $match['identifier'];
     $this->symbolicType = $match['type'];
   }
 
   public function getRefAlternatives() {
     // Make sure we've resolved the reference into a stable commit first.
     try {
       $this->getStableCommit();
     } catch (DiffusionRefNotFoundException $ex) {
       // If we have a bad reference, just return the empty set of
       // alternatives.
     }
     return $this->refAlternatives;
   }
 
   private function chooseBestRefMatch($ref, array $results) {
     // First, filter out less-desirable matches.
     $candidates = array();
     foreach ($results as $result) {
       // Exclude closed heads.
       if ($result['type'] == 'branch') {
         if (idx($result, 'closed')) {
           continue;
         }
       }
 
       $candidates[] = $result;
     }
 
     // If we filtered everything, undo the filtering.
     if (!$candidates) {
       $candidates = $results;
     }
 
     // TODO: Do a better job of selecting the best match?
     $match = head($candidates);
 
     // After choosing the best alternative, save all the alternatives so the
     // UI can show them to the user.
     if (count($candidates) > 1) {
       $this->refAlternatives = $candidates;
     }
 
     return $match;
   }
 
   public function resolveRefs(array $refs, array $types = array()) {
     // First, try to resolve refs from fast cache sources.
     $cached_query = id(new DiffusionCachedResolveRefsQuery())
       ->setRepository($this->getRepository())
       ->withRefs($refs);
 
     if ($types) {
       $cached_query->withTypes($types);
     }
 
     $cached_results = $cached_query->execute();
 
     // Throw away all the refs we resolved. Hopefully, we'll throw away
     // everything here.
     foreach ($refs as $key => $ref) {
       if (isset($cached_results[$ref])) {
         unset($refs[$key]);
       }
     }
 
     // If we couldn't pull everything out of the cache, execute the underlying
     // VCS operation.
     if ($refs) {
       $vcs_results = DiffusionQuery::callConduitWithDiffusionRequest(
         $this->getUser(),
         $this,
         'diffusion.resolverefs',
         array(
           'types' => $types,
           'refs' => $refs,
         ));
     } else {
       $vcs_results = array();
     }
 
     return $vcs_results + $cached_results;
   }
 
   public function setIsClusterRequest($is_cluster_request) {
     $this->isClusterRequest = $is_cluster_request;
     return $this;
   }
 
   public function getIsClusterRequest() {
     return $this->isClusterRequest;
   }
 
 }
diff --git a/src/applications/diffusion/view/DiffusionReadmeView.php b/src/applications/diffusion/view/DiffusionReadmeView.php
index 2f65f5730f..72b7a94f5b 100644
--- a/src/applications/diffusion/view/DiffusionReadmeView.php
+++ b/src/applications/diffusion/view/DiffusionReadmeView.php
@@ -1,115 +1,115 @@
 <?php
 
 final class DiffusionReadmeView extends DiffusionView {
 
   private $path;
   private $content;
 
   public function setPath($path) {
     $this->path = $path;
     return $this;
   }
 
   public function getPath() {
     return $this->path;
   }
 
   public function setContent($content) {
     $this->content = $content;
     return $this;
   }
 
   public function getContent() {
     return $this->content;
   }
 
   /**
    * Get the markup language a README should be interpreted as.
    *
-   * @param string Local README path, like "README.txt".
+   * @param string $path Local README path, like "README.txt".
    * @return string Best markup interpreter (like "remarkup") for this file.
    */
    private function getReadmeLanguage($path) {
     $path = phutil_utf8_strtolower($path);
     if ($path == 'readme') {
       return 'remarkup';
     }
 
     $ext = last(explode('.', $path));
     switch ($ext) {
       case 'remarkup':
       case 'md':
         return 'remarkup';
       case 'rainbow':
         return 'rainbow';
       case 'txt':
       default:
         return 'text';
     }
   }
 
 
   public function render() {
     $readme_path = $this->getPath();
     $readme_name = basename($readme_path);
     $interpreter = $this->getReadmeLanguage($readme_name);
     require_celerity_resource('diffusion-readme-css');
 
     $content = $this->getContent();
 
     $class = null;
     switch ($interpreter) {
       case 'remarkup':
         // TODO: This is sketchy, but make sure we hit the markup cache.
         $markup_object = id(new PhabricatorMarkupOneOff())
           ->setEngineRuleset('diffusion-readme')
           ->setContent($content);
         $markup_field = 'default';
 
         $content = id(new PhabricatorMarkupEngine())
           ->setViewer($this->getUser())
           ->addObject($markup_object, $markup_field)
           ->process()
           ->getOutput($markup_object, $markup_field);
 
         $engine = $markup_object->newMarkupEngine($markup_field);
 
         $readme_content = $content;
         $class = 'ml';
         break;
       case 'rainbow':
         $content = id(new PhutilRainbowSyntaxHighlighter())
           ->getHighlightFuture($content)
           ->resolve();
         $readme_content = phutil_escape_html_newlines($content);
 
         require_celerity_resource('syntax-highlighting-css');
         $class = 'remarkup-code ml';
         break;
       default:
       case 'text':
         $readme_content = phutil_escape_html_newlines($content);
         $class = 'ml';
         break;
     }
 
     $readme_content = phutil_tag(
       'div',
       array(
         'class' => $class,
       ),
       $readme_content);
 
     $header = id(new PHUIHeaderView())
       ->setHeader($readme_name)
       ->addClass('diffusion-panel-header-view');
 
     return id(new PHUIObjectBoxView())
       ->setHeader($header)
       ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
       ->addClass('diffusion-mobile-view')
       ->appendChild($readme_content)
       ->addClass('diffusion-readme-view');
   }
 
 }
diff --git a/src/applications/diviner/query/DivinerAtomQuery.php b/src/applications/diviner/query/DivinerAtomQuery.php
index 1ca7aad984..278b0a4234 100644
--- a/src/applications/diviner/query/DivinerAtomQuery.php
+++ b/src/applications/diviner/query/DivinerAtomQuery.php
@@ -1,511 +1,515 @@
 <?php
 
 final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $ids;
   private $phids;
   private $bookPHIDs;
   private $names;
   private $types;
   private $contexts;
   private $indexes;
   private $isDocumentable;
   private $isGhost;
   private $nodeHashes;
   private $titles;
   private $nameContains;
   private $repositoryPHIDs;
 
   private $needAtoms;
   private $needExtends;
   private $needChildren;
   private $needRepositories;
 
   public function withIDs(array $ids) {
     $this->ids = $ids;
     return $this;
   }
 
   public function withPHIDs(array $phids) {
     $this->phids = $phids;
     return $this;
   }
 
   public function withBookPHIDs(array $phids) {
     $this->bookPHIDs = $phids;
     return $this;
   }
 
   public function withTypes(array $types) {
     $this->types = $types;
     return $this;
   }
 
   public function withNames(array $names) {
     $this->names = $names;
     return $this;
   }
 
   public function withContexts(array $contexts) {
     $this->contexts = $contexts;
     return $this;
   }
 
   public function withIndexes(array $indexes) {
     $this->indexes = $indexes;
     return $this;
   }
 
   public function withNodeHashes(array $hashes) {
     $this->nodeHashes = $hashes;
     return $this;
   }
 
   public function withTitles($titles) {
     $this->titles = $titles;
     return $this;
   }
 
   public function withNameContains($text) {
     $this->nameContains = $text;
     return $this;
   }
 
   public function needAtoms($need) {
     $this->needAtoms = $need;
     return $this;
   }
 
   public function needChildren($need) {
     $this->needChildren = $need;
     return $this;
   }
 
   /**
    * Include or exclude "ghosts", which are symbols which used to exist but do
    * not exist currently (for example, a function which existed in an older
    * version of the codebase but was deleted).
    *
    * These symbols had PHIDs assigned to them, and may have other sorts of
    * metadata that we don't want to lose (like comments or flags), so we don't
    * delete them outright. They might also come back in the future: the change
    * which deleted the symbol might be reverted, or the documentation might
    * have been generated incorrectly by accident. In these cases, we can
    * restore the original data.
    *
-   * @param bool
+   * @param bool $ghosts
    * @return this
    */
   public function withGhosts($ghosts) {
     $this->isGhost = $ghosts;
     return $this;
   }
 
   public function needExtends($need) {
     $this->needExtends = $need;
     return $this;
   }
 
   public function withIsDocumentable($documentable) {
     $this->isDocumentable = $documentable;
     return $this;
   }
 
   public function withRepositoryPHIDs(array $repository_phids) {
     $this->repositoryPHIDs = $repository_phids;
     return $this;
   }
 
   public function needRepositories($need_repositories) {
     $this->needRepositories = $need_repositories;
     return $this;
   }
 
   protected function loadPage() {
     $table = new DivinerLiveSymbol();
     $conn_r = $table->establishConnection('r');
 
     $data = queryfx_all(
       $conn_r,
       'SELECT * FROM %T %Q %Q %Q',
       $table->getTableName(),
       $this->buildWhereClause($conn_r),
       $this->buildOrderClause($conn_r),
       $this->buildLimitClause($conn_r));
 
     return $table->loadAllFromArray($data);
   }
 
   protected function willFilterPage(array $atoms) {
     assert_instances_of($atoms, 'DivinerLiveSymbol');
 
     $books = array_unique(mpull($atoms, 'getBookPHID'));
 
     $books = id(new DivinerBookQuery())
       ->setViewer($this->getViewer())
       ->withPHIDs($books)
       ->execute();
     $books = mpull($books, null, 'getPHID');
 
     foreach ($atoms as $key => $atom) {
       $book = idx($books, $atom->getBookPHID());
       if (!$book) {
         $this->didRejectResult($atom);
         unset($atoms[$key]);
         continue;
       }
       $atom->attachBook($book);
     }
 
     if ($this->needAtoms) {
       $atom_data = id(new DivinerLiveAtom())->loadAllWhere(
         'symbolPHID IN (%Ls)',
         mpull($atoms, 'getPHID'));
       $atom_data = mpull($atom_data, null, 'getSymbolPHID');
 
       foreach ($atoms as $key => $atom) {
         $data = idx($atom_data, $atom->getPHID());
         $atom->attachAtom($data);
       }
     }
 
     // Load all of the symbols this symbol extends, recursively. Commonly,
     // this means all the ancestor classes and interfaces it extends and
     // implements.
     if ($this->needExtends) {
       // First, load all the matching symbols by name. This does 99% of the
       // work in most cases, assuming things are named at all reasonably.
       $names = array();
       foreach ($atoms as $atom) {
         if (!$atom->getAtom()) {
           continue;
         }
 
         foreach ($atom->getAtom()->getExtends() as $xref) {
           $names[] = $xref->getName();
         }
       }
 
       if ($names) {
         $xatoms = id(new DivinerAtomQuery())
           ->setViewer($this->getViewer())
           ->withNames($names)
           ->withGhosts(false)
           ->needExtends(true)
           ->needAtoms(true)
           ->needChildren($this->needChildren)
           ->execute();
         $xatoms = mgroup($xatoms, 'getName', 'getType', 'getBookPHID');
       } else {
         $xatoms = array();
       }
 
       foreach ($atoms as $atom) {
         $atom_lang    = null;
         $atom_extends = array();
 
         if ($atom->getAtom()) {
           $atom_lang    = $atom->getAtom()->getLanguage();
           $atom_extends = $atom->getAtom()->getExtends();
         }
 
         $extends = array();
 
         foreach ($atom_extends as $xref) {
           // If there are no symbols of the matching name and type, we can't
           // resolve this.
           if (empty($xatoms[$xref->getName()][$xref->getType()])) {
             continue;
           }
 
           // If we found matches in the same documentation book, prefer them
           // over other matches. Otherwise, look at all the matches.
           $matches = $xatoms[$xref->getName()][$xref->getType()];
           if (isset($matches[$atom->getBookPHID()])) {
             $maybe = $matches[$atom->getBookPHID()];
           } else {
             $maybe = array_mergev($matches);
           }
 
           if (!$maybe) {
             continue;
           }
 
           // Filter out matches in a different language, since, e.g., PHP
           // classes can not implement JS classes.
           $same_lang = array();
           foreach ($maybe as $xatom) {
             if ($xatom->getAtom()->getLanguage() == $atom_lang) {
               $same_lang[] = $xatom;
             }
           }
 
           if (!$same_lang) {
             continue;
           }
 
           // If we have duplicates remaining, just pick the first one. There's
           // nothing more we can do to figure out which is the real one.
           $extends[] = head($same_lang);
         }
 
         $atom->attachExtends($extends);
       }
     }
 
     if ($this->needChildren) {
       $child_hashes = $this->getAllChildHashes($atoms, $this->needExtends);
 
       if ($child_hashes) {
         $children = id(new DivinerAtomQuery())
           ->setViewer($this->getViewer())
           ->withNodeHashes($child_hashes)
           ->needAtoms($this->needAtoms)
           ->execute();
 
         $children = mpull($children, null, 'getNodeHash');
       } else {
         $children = array();
       }
 
       $this->attachAllChildren($atoms, $children, $this->needExtends);
     }
 
     if ($this->needRepositories) {
       $repositories = id(new PhabricatorRepositoryQuery())
         ->setViewer($this->getViewer())
         ->withPHIDs(mpull($atoms, 'getRepositoryPHID'))
         ->execute();
       $repositories = mpull($repositories, null, 'getPHID');
 
       foreach ($atoms as $key => $atom) {
         if ($atom->getRepositoryPHID() === null) {
           $atom->attachRepository(null);
           continue;
         }
 
         $repository = idx($repositories, $atom->getRepositoryPHID());
 
         if (!$repository) {
           $this->didRejectResult($atom);
           unset($atom[$key]);
           continue;
         }
 
         $atom->attachRepository($repository);
       }
     }
 
     return $atoms;
   }
 
   protected function buildWhereClause(AphrontDatabaseConnection $conn) {
     $where = array();
 
     if ($this->ids) {
       $where[] = qsprintf(
         $conn,
         'id IN (%Ld)',
         $this->ids);
     }
 
     if ($this->phids) {
       $where[] = qsprintf(
         $conn,
         'phid IN (%Ls)',
         $this->phids);
     }
 
     if ($this->bookPHIDs) {
       $where[] = qsprintf(
         $conn,
         'bookPHID IN (%Ls)',
         $this->bookPHIDs);
     }
 
     if ($this->types) {
       $where[] = qsprintf(
         $conn,
         'type IN (%Ls)',
         $this->types);
     }
 
     if ($this->names) {
       $where[] = qsprintf(
         $conn,
         'name IN (%Ls)',
         $this->names);
     }
 
     if ($this->titles) {
       $hashes = array();
 
       foreach ($this->titles as $title) {
         $slug = DivinerAtomRef::normalizeTitleString($title);
         $hash = PhabricatorHash::digestForIndex($slug);
         $hashes[] = $hash;
       }
 
       $where[] = qsprintf(
         $conn,
         'titleSlugHash in (%Ls)',
         $hashes);
     }
 
     if ($this->contexts) {
       $with_null = false;
       $contexts = $this->contexts;
 
       foreach ($contexts as $key => $value) {
         if ($value === null) {
           unset($contexts[$key]);
           $with_null = true;
           continue;
         }
       }
 
       if ($contexts && $with_null) {
         $where[] = qsprintf(
           $conn,
           'context IN (%Ls) OR context IS NULL',
           $contexts);
       } else if ($contexts) {
         $where[] = qsprintf(
           $conn,
           'context IN (%Ls)',
           $contexts);
       } else if ($with_null) {
         $where[] = qsprintf(
           $conn,
           'context IS NULL');
       }
     }
 
     if ($this->indexes) {
       $where[] = qsprintf(
         $conn,
         'atomIndex IN (%Ld)',
         $this->indexes);
     }
 
     if ($this->isDocumentable !== null) {
       $where[] = qsprintf(
         $conn,
         'isDocumentable = %d',
         (int)$this->isDocumentable);
     }
 
     if ($this->isGhost !== null) {
       if ($this->isGhost) {
         $where[] = qsprintf($conn, 'graphHash IS NULL');
       } else {
         $where[] = qsprintf($conn, 'graphHash IS NOT NULL');
       }
     }
 
     if ($this->nodeHashes) {
       $where[] = qsprintf(
         $conn,
         'nodeHash IN (%Ls)',
         $this->nodeHashes);
     }
 
     if ($this->nameContains) {
       // NOTE: This `CONVERT()` call makes queries case-insensitive, since
       // the column has binary collation. Eventually, this should move into
       // fulltext.
       $where[] = qsprintf(
         $conn,
         'CONVERT(name USING utf8) LIKE %~',
         $this->nameContains);
     }
 
     if ($this->repositoryPHIDs) {
       $where[] = qsprintf(
         $conn,
         'repositoryPHID IN (%Ls)',
         $this->repositoryPHIDs);
     }
 
     $where[] = $this->buildPagingClause($conn);
 
     return $this->formatWhereClause($conn, $where);
   }
 
   /**
    * Walk a list of atoms and collect all the node hashes of the atoms'
    * children. When recursing, also walk up the tree and collect children of
    * atoms they extend.
    *
-   * @param list<DivinerLiveSymbol> List of symbols to collect child hashes of.
-   * @param bool                    True to collect children of extended atoms,
-   *                                as well.
+   * @param list<DivinerLiveSymbol> $symbols List of symbols to collect child
+   *                                hashes of.
+   * @param bool                    $recurse_up True to collect children of
+   *                                extended atoms, as well.
    * @return map<string, string>    Hashes of atoms' children.
    */
   private function getAllChildHashes(array $symbols, $recurse_up) {
     assert_instances_of($symbols, 'DivinerLiveSymbol');
 
     $hashes = array();
     foreach ($symbols as $symbol) {
       $child_hashes = array();
 
       if ($symbol->getAtom()) {
         $child_hashes = $symbol->getAtom()->getChildHashes();
       }
 
       foreach ($child_hashes as $hash) {
         $hashes[$hash] = $hash;
       }
 
       if ($recurse_up) {
         $hashes += $this->getAllChildHashes($symbol->getExtends(), true);
       }
     }
 
     return $hashes;
   }
 
   /**
    * Attach child atoms to existing atoms. In recursive mode, also attach child
    * atoms to atoms that these atoms extend.
    *
-   * @param list<DivinerLiveSymbol> List of symbols to attach children to.
-   * @param map<string, DivinerLiveSymbol> Map of symbols, keyed by node hash.
-   * @param bool True to attach children to extended atoms, as well.
+   * @param list<DivinerLiveSymbol> $symbols List of symbols to attach children
+   *   to.
+   * @param map<string, DivinerLiveSymbol> $children Map of symbols, keyed by
+   *   node hash.
+   * @param bool $recurse_up True to attach children to extended atoms, as
+   *   well.
    * @return void
    */
   private function attachAllChildren(
     array $symbols,
     array $children,
     $recurse_up) {
 
     assert_instances_of($symbols, 'DivinerLiveSymbol');
     assert_instances_of($children, 'DivinerLiveSymbol');
 
     foreach ($symbols as $symbol) {
       $child_hashes = array();
       $symbol_children = array();
 
       if ($symbol->getAtom()) {
         $child_hashes = $symbol->getAtom()->getChildHashes();
       }
 
       foreach ($child_hashes as $hash) {
         if (isset($children[$hash])) {
           $symbol_children[] = $children[$hash];
         }
       }
 
       $symbol->attachChildren($symbol_children);
 
       if ($recurse_up) {
         $this->attachAllChildren($symbol->getExtends(), $children, true);
       }
     }
   }
 
   public function getQueryApplicationClass() {
     return PhabricatorDivinerApplication::class;
   }
 
 }
diff --git a/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php b/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
index 46ca3f3ac9..ddd45ce802 100644
--- a/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
+++ b/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
@@ -1,101 +1,102 @@
 <?php
 
 /**
  * @task config Configuration
  */
 abstract class DoorkeeperFeedStoryPublisher extends Phobject {
 
   private $feedStory;
   private $viewer;
   private $renderWithImpliedContext;
 
 
 /* -(  Configuration  )------------------------------------------------------ */
 
 
   /**
    * Render story text using contextual language to identify the object the
    * story is about, instead of the full object name. For example, without
    * contextual language a story might render like this:
    *
    *   alincoln created D123: Chop Wood for Log Cabin v2.0
    *
    * With contextual language, it will render like this instead:
    *
    *   alincoln created this revision.
    *
    * If the interface where the text will be displayed is specific to an
    * individual object (like Asana tasks that represent one review or commit
    * are), it's generally more natural to use language that assumes context.
    * If the target context may show information about several objects (like
    * JIRA issues which can have several linked revisions), it's generally
    * more useful not to assume context.
    *
-   * @param bool  True to assume object context when rendering.
+   * @param bool $render_with_implied_context True to assume object context
+   *  when rendering.
    * @return this
    * @task config
    */
   public function setRenderWithImpliedContext($render_with_implied_context) {
     $this->renderWithImpliedContext = $render_with_implied_context;
     return $this;
   }
 
   /**
    * Determine if rendering should assume object context. For discussion, see
    * @{method:setRenderWithImpliedContext}.
    *
    * @return bool True if rendering should assume object context is implied.
    * @task config
    */
   public function getRenderWithImpliedContext() {
     return $this->renderWithImpliedContext;
   }
 
   public function setFeedStory(PhabricatorFeedStory $feed_story) {
     $this->feedStory = $feed_story;
     return $this;
   }
 
   public function getFeedStory() {
     return $this->feedStory;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   abstract public function canPublishStory(
     PhabricatorFeedStory $story,
     $object);
 
   /**
    * Hook for publishers to mutate the story object, particularly by loading
    * and attaching additional data.
    */
   public function willPublishStory($object) {
     return $object;
   }
 
 
   public function getStoryText($object) {
     return $this->getFeedStory()->renderAsTextForDoorkeeper($this);
   }
 
   abstract public function isStoryAboutObjectCreation($object);
   abstract public function isStoryAboutObjectClosure($object);
   abstract public function getOwnerPHID($object);
   abstract public function getActiveUserPHIDs($object);
   abstract public function getPassiveUserPHIDs($object);
   abstract public function getCCUserPHIDs($object);
   abstract public function getObjectTitle($object);
   abstract public function getObjectURI($object);
   abstract public function getObjectDescription($object);
   abstract public function isObjectClosed($object);
   abstract public function getResponsibilityTitle($object);
 
 }
diff --git a/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php b/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php
index 27ba6e623f..bfa039d39f 100644
--- a/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php
+++ b/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php
@@ -1,543 +1,545 @@
 <?php
 
 /**
  * @task lease Lease Acquisition
  * @task resource Resource Allocation
  * @task interface Resource Interfaces
  * @task log Logging
  */
 abstract class DrydockBlueprintImplementation extends Phobject {
 
   abstract public function getType();
 
   abstract public function isEnabled();
 
   abstract public function getBlueprintName();
   abstract public function getDescription();
 
   public function getBlueprintIcon() {
     return 'fa-map-o';
   }
 
   public function getFieldSpecifications() {
     $fields = array();
 
     $fields += $this->getCustomFieldSpecifications();
 
     if ($this->shouldUseConcurrentResourceLimit()) {
       $fields += array(
         'allocator.limit' => array(
           'name' => pht('Limit'),
           'caption' => pht(
             'Maximum number of resources this blueprint can have active '.
             'concurrently.'),
           'type' => 'int',
         ),
       );
     }
 
     return $fields;
   }
 
   protected function getCustomFieldSpecifications() {
     return array();
   }
 
   public function getViewer() {
     return PhabricatorUser::getOmnipotentUser();
   }
 
 
 /* -(  Lease Acquisition  )-------------------------------------------------- */
 
 
   /**
    * Enforce basic checks on lease/resource compatibility. Allows resources to
    * reject leases if they are incompatible, even if the resource types match.
    *
    * For example, if a resource represents a 32-bit host, this method might
    * reject leases that need a 64-bit host. The blueprint might also reject
    * a resource if the lease needs 8GB of RAM and the resource only has 6GB
    * free.
    *
    * This method should not acquire locks or expect anything to be locked. This
    * is a coarse compatibility check between a lease and a resource.
    *
-   * @param DrydockBlueprint Concrete blueprint to allocate for.
-   * @param DrydockResource Candidate resource to allocate the lease on.
-   * @param DrydockLease Pending lease that wants to allocate here.
+   * @param DrydockBlueprint $blueprint Concrete blueprint to allocate for.
+   * @param DrydockResource $resource Candidate resource to allocate the lease
+   *   on.
+   * @param DrydockLease $lease Pending lease that wants to allocate here.
    * @return bool True if the resource and lease are compatible.
    * @task lease
    */
   abstract public function canAcquireLeaseOnResource(
     DrydockBlueprint $blueprint,
     DrydockResource $resource,
     DrydockLease $lease);
 
 
   /**
    * Acquire a lease. Allows resources to perform setup as leases are brought
    * online.
    *
    * If acquisition fails, throw an exception.
    *
-   * @param DrydockBlueprint Blueprint which built the resource.
-   * @param DrydockResource Resource to acquire a lease on.
-   * @param DrydockLease Requested lease.
+   * @param DrydockBlueprint $blueprint Blueprint which built the resource.
+   * @param DrydockResource $resource Resource to acquire a lease on.
+   * @param DrydockLease $lease Requested lease.
    * @return void
    * @task lease
    */
   abstract public function acquireLease(
     DrydockBlueprint $blueprint,
     DrydockResource $resource,
     DrydockLease $lease);
 
 
   /**
    * @return void
    * @task lease
    */
   public function activateLease(
     DrydockBlueprint $blueprint,
     DrydockResource $resource,
     DrydockLease $lease) {
     throw new PhutilMethodNotImplementedException();
   }
 
 
   /**
    * React to a lease being released.
    *
    * This callback is primarily useful for automatically releasing resources
    * once all leases are released.
    *
-   * @param DrydockBlueprint Blueprint which built the resource.
-   * @param DrydockResource Resource a lease was released on.
-   * @param DrydockLease Recently released lease.
+   * @param DrydockBlueprint $blueprint Blueprint which built the resource.
+   * @param DrydockResource $resource Resource a lease was released on.
+   * @param DrydockLease $lease Recently released lease.
    * @return void
    * @task lease
    */
   abstract public function didReleaseLease(
     DrydockBlueprint $blueprint,
     DrydockResource $resource,
     DrydockLease $lease);
 
 
   /**
    * Destroy any temporary data associated with a lease.
    *
    * If a lease creates temporary state while held, destroy it here.
    *
-   * @param DrydockBlueprint Blueprint which built the resource.
-   * @param DrydockResource Resource the lease is acquired on.
-   * @param DrydockLease The lease being destroyed.
+   * @param DrydockBlueprint $blueprint Blueprint which built the resource.
+   * @param DrydockResource $resource Resource the lease is acquired on.
+   * @param DrydockLease $lease The lease being destroyed.
    * @return void
    * @task lease
    */
   abstract public function destroyLease(
     DrydockBlueprint $blueprint,
     DrydockResource $resource,
     DrydockLease $lease);
 
   /**
    * Return true to try to allocate a new resource and expand the resource
    * pool instead of permitting an otherwise valid acquisition on an existing
    * resource.
    *
    * This allows the blueprint to provide a soft hint about when the resource
    * pool should grow.
    *
    * Returning "true" in all cases generally makes sense when a blueprint
    * controls a fixed pool of resources, like a particular number of physical
    * hosts: you want to put all the hosts in service, so whenever it is
    * possible to allocate a new host you want to do this.
    *
    * Returning "false" in all cases generally make sense when a blueprint
    * has a flexible pool of expensive resources and you want to pack leases
    * onto them as tightly as possible.
    *
-   * @param DrydockBlueprint The blueprint for an existing resource being
-   *   acquired.
-   * @param DrydockResource The resource being acquired, which we may want to
-   *   build a supplemental resource for.
-   * @param DrydockLease The current lease performing acquisition.
+   * @param DrydockBlueprint $blueprint The blueprint for an existing resource
+   *   being acquired.
+   * @param DrydockResource $resource The resource being acquired, which we may
+   *   want to build a supplemental resource for.
+   * @param DrydockLease $lease The current lease performing acquisition.
    * @return bool True to prefer allocating a supplemental resource.
    *
    * @task lease
    */
   public function shouldAllocateSupplementalResource(
     DrydockBlueprint $blueprint,
     DrydockResource $resource,
     DrydockLease $lease) {
     return false;
   }
 
 /* -(  Resource Allocation  )------------------------------------------------ */
 
 
   /**
    * Enforce fundamental implementation/lease checks. Allows implementations to
    * reject a lease which no concrete blueprint can ever satisfy.
    *
    * For example, if a lease only builds ARM hosts and the lease needs a
    * PowerPC host, it may be rejected here.
    *
    * This is the earliest rejection phase, and followed by
    * @{method:canEverAllocateResourceForLease}.
    *
    * This method should not actually check if a resource can be allocated
    * right now, or even if a blueprint which can allocate a suitable resource
    * really exists, only if some blueprint may conceivably exist which could
    * plausibly be able to build a suitable resource.
    *
-   * @param DrydockLease Requested lease.
+   * @param DrydockLease $lease Requested lease.
    * @return bool True if some concrete blueprint of this implementation's
    *   type might ever be able to build a resource for the lease.
    * @task resource
    */
   abstract public function canAnyBlueprintEverAllocateResourceForLease(
     DrydockLease $lease);
 
 
   /**
    * Enforce basic blueprint/lease checks. Allows blueprints to reject a lease
    * which they can not build a resource for.
    *
    * This is the second rejection phase. It follows
    * @{method:canAnyBlueprintEverAllocateResourceForLease} and is followed by
    * @{method:canAllocateResourceForLease}.
    *
    * This method should not check if a resource can be built right now, only
    * if the blueprint as configured may, at some time, be able to build a
    * suitable resource.
    *
-   * @param DrydockBlueprint Blueprint which may be asked to allocate a
-   *   resource.
-   * @param DrydockLease Requested lease.
+   * @param DrydockBlueprint $blueprint Blueprint which may be asked to
+   *   allocate a resource.
+   * @param DrydockLease $lease Requested lease.
    * @return bool True if this blueprint can eventually build a suitable
    *   resource for the lease, as currently configured.
    * @task resource
    */
   abstract public function canEverAllocateResourceForLease(
     DrydockBlueprint $blueprint,
     DrydockLease $lease);
 
 
   /**
    * Enforce basic availability limits. Allows blueprints to reject resource
    * allocation if they are currently overallocated.
    *
    * This method should perform basic capacity/limit checks. For example, if
    * it has a limit of 6 resources and currently has 6 resources allocated,
    * it might reject new leases.
    *
    * This method should not acquire locks or expect locks to be acquired. This
    * is a coarse check to determine if the operation is likely to succeed
    * right now without needing to acquire locks.
    *
    * It is expected that this method will sometimes return `true` (indicating
    * that a resource can be allocated) but find that another allocator has
    * eaten up free capacity by the time it actually tries to build a resource.
    * This is normal and the allocator will recover from it.
    *
-   * @param DrydockBlueprint The blueprint which may be asked to allocate a
-   *   resource.
-   * @param DrydockLease Requested lease.
+   * @param DrydockBlueprint $blueprint The blueprint which may be asked to
+   *   allocate a resource.
+   * @param DrydockLease $lease Requested lease.
    * @return bool True if this blueprint appears likely to be able to allocate
    *   a suitable resource.
    * @task resource
    */
   abstract public function canAllocateResourceForLease(
     DrydockBlueprint $blueprint,
     DrydockLease $lease);
 
 
   /**
    * Allocate a suitable resource for a lease.
    *
    * This method MUST acquire, hold, and manage locks to prevent multiple
    * allocations from racing. World state is not locked before this method is
    * called. Blueprints are entirely responsible for any lock handling they
    * need to perform.
    *
-   * @param DrydockBlueprint The blueprint which should allocate a resource.
-   * @param DrydockLease Requested lease.
+   * @param DrydockBlueprint $blueprint The blueprint which should allocate a
+   *   resource.
+   * @param DrydockLease $lease Requested lease.
    * @return DrydockResource Allocated resource.
    * @task resource
    */
   abstract public function allocateResource(
     DrydockBlueprint $blueprint,
     DrydockLease $lease);
 
 
   /**
    * @task resource
    */
   public function activateResource(
     DrydockBlueprint $blueprint,
     DrydockResource $resource) {
     throw new PhutilMethodNotImplementedException();
   }
 
 
   /**
    * Destroy any temporary data associated with a resource.
    *
    * If a resource creates temporary state when allocated, destroy that state
    * here. For example, you might shut down a virtual host or destroy a working
    * copy on disk.
    *
-   * @param DrydockBlueprint Blueprint which built the resource.
-   * @param DrydockResource Resource being destroyed.
+   * @param DrydockBlueprint $blueprint Blueprint which built the resource.
+   * @param DrydockResource $resource Resource being destroyed.
    * @return void
    * @task resource
    */
   abstract public function destroyResource(
     DrydockBlueprint $blueprint,
     DrydockResource $resource);
 
 
   /**
    * Get a human readable name for a resource.
    *
-   * @param DrydockBlueprint Blueprint which built the resource.
-   * @param DrydockResource Resource to get the name of.
+   * @param DrydockBlueprint $blueprint Blueprint which built the resource.
+   * @param DrydockResource $resource Resource to get the name of.
    * @return string Human-readable resource name.
    * @task resource
    */
   abstract public function getResourceName(
     DrydockBlueprint $blueprint,
     DrydockResource $resource);
 
 
 /* -(  Resource Interfaces  )------------------------------------------------ */
 
 
   abstract public function getInterface(
     DrydockBlueprint $blueprint,
     DrydockResource $resource,
     DrydockLease $lease,
     $type);
 
 
 /* -(  Logging  )------------------------------------------------------------ */
 
 
   public static function getAllBlueprintImplementations() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->execute();
   }
 
 
   /**
    * Get all the @{class:DrydockBlueprintImplementation}s which can possibly
    * build a resource to satisfy a lease.
    *
    * This method returns blueprints which might, at some time, be able to
    * build a resource which can satisfy the lease. They may not be able to
    * build that resource right now.
    *
-   * @param DrydockLease Requested lease.
+   * @param DrydockLease $lease Requested lease.
    * @return list<DrydockBlueprintImplementation> List of qualifying blueprint
    *   implementations.
    */
   public static function getAllForAllocatingLease(
     DrydockLease $lease) {
 
     $impls = self::getAllBlueprintImplementations();
 
     $keep = array();
     foreach ($impls as $key => $impl) {
       // Don't use disabled blueprint types.
       if (!$impl->isEnabled()) {
         continue;
       }
 
       // Don't use blueprint types which can't allocate the correct kind of
       // resource.
       if ($impl->getType() != $lease->getResourceType()) {
         continue;
       }
 
       if (!$impl->canAnyBlueprintEverAllocateResourceForLease($lease)) {
         continue;
       }
 
       $keep[$key] = $impl;
     }
 
     return $keep;
   }
 
   public static function getNamedImplementation($class) {
     return idx(self::getAllBlueprintImplementations(), $class);
   }
 
   protected function newResourceTemplate(DrydockBlueprint $blueprint) {
 
     $resource = id(new DrydockResource())
       ->setBlueprintPHID($blueprint->getPHID())
       ->attachBlueprint($blueprint)
       ->setType($this->getType())
       ->setStatus(DrydockResourceStatus::STATUS_PENDING);
 
     // Pre-allocate the resource PHID.
     $resource->setPHID($resource->generatePHID());
 
     return $resource;
   }
 
   protected function newLease(DrydockBlueprint $blueprint) {
     return DrydockLease::initializeNewLease()
       ->setAuthorizingPHID($blueprint->getPHID());
   }
 
   protected function requireActiveLease(DrydockLease $lease) {
     $lease_status = $lease->getStatus();
 
     switch ($lease_status) {
       case DrydockLeaseStatus::STATUS_PENDING:
       case DrydockLeaseStatus::STATUS_ACQUIRED:
         throw new PhabricatorWorkerYieldException(15);
       case DrydockLeaseStatus::STATUS_ACTIVE:
         return;
       default:
         throw new Exception(
           pht(
             'Lease ("%s") is in bad state ("%s"), expected "%s".',
             $lease->getPHID(),
             $lease_status,
             DrydockLeaseStatus::STATUS_ACTIVE));
     }
   }
 
 
   /**
    * Does this implementation use concurrent resource limits?
    *
    * Implementations can override this method to opt into standard limit
    * behavior, which provides a simple concurrent resource limit.
    *
    * @return bool True to use limits.
    */
   protected function shouldUseConcurrentResourceLimit() {
     return false;
   }
 
 
   /**
    * Get the effective concurrent resource limit for this blueprint.
    *
-   * @param DrydockBlueprint Blueprint to get the limit for.
+   * @param DrydockBlueprint $blueprint Blueprint to get the limit for.
    * @return int|null Limit, or `null` for no limit.
    */
   protected function getConcurrentResourceLimit(DrydockBlueprint $blueprint) {
     if ($this->shouldUseConcurrentResourceLimit()) {
       $limit = $blueprint->getFieldValue('allocator.limit');
       $limit = (int)$limit;
       if ($limit > 0) {
         return $limit;
       } else {
         return null;
       }
     }
 
     return null;
   }
 
 
   protected function getConcurrentResourceLimitSlotLock(
     DrydockBlueprint $blueprint) {
 
     $limit = $this->getConcurrentResourceLimit($blueprint);
     if ($limit === null) {
       return;
     }
 
     $blueprint_phid = $blueprint->getPHID();
 
     // TODO: This logic shouldn't do anything awful, but is a little silly. It
     // would be nice to unify the "huge limit" and "small limit" cases
     // eventually but it's a little tricky.
 
     // If the limit is huge, just pick a random slot. This is just stopping
     // us from exploding if someone types a billion zillion into the box.
     if ($limit > 1024) {
       $slot = mt_rand(0, $limit - 1);
       return "allocator({$blueprint_phid}).limit({$slot})";
     }
 
     // For reasonable limits, actually check for an available slot.
     $slots = range(0, $limit - 1);
     shuffle($slots);
 
     $lock_names = array();
     foreach ($slots as $slot) {
       $lock_names[] = "allocator({$blueprint_phid}).limit({$slot})";
     }
 
     $locks = DrydockSlotLock::loadHeldLocks($lock_names);
     $locks = mpull($locks, null, 'getLockKey');
 
     foreach ($lock_names as $lock_name) {
       if (empty($locks[$lock_name])) {
         return $lock_name;
       }
     }
 
     // If we found no free slot, just return whatever we checked last (which
     // is just a random slot). There's a small chance we'll get lucky and the
     // lock will be free by the time we try to take it, but usually we'll just
     // fail to grab the lock, throw an appropriate lock exception, and get back
     // on the right path to retry later.
 
     return $lock_name;
   }
 
 
 
   /**
    * Apply standard limits on resource allocation rate.
    *
-   * @param DrydockBlueprint The blueprint requesting an allocation.
+   * @param DrydockBlueprint $blueprint The blueprint requesting an allocation.
    * @return bool True if further allocations should be limited.
    */
   protected function shouldLimitAllocatingPoolSize(
     DrydockBlueprint $blueprint) {
 
     // Limit on total number of active resources.
     $total_limit = $this->getConcurrentResourceLimit($blueprint);
     if ($total_limit === null) {
       return false;
     }
 
     $resource = new DrydockResource();
     $conn = $resource->establishConnection('r');
 
     $counts = queryfx_all(
       $conn,
       'SELECT status, COUNT(*) N FROM %R
         WHERE blueprintPHID = %s AND status != %s
         GROUP BY status',
       $resource,
       $blueprint->getPHID(),
       DrydockResourceStatus::STATUS_DESTROYED);
     $counts = ipull($counts, 'N', 'status');
 
     $n_alloc = idx($counts, DrydockResourceStatus::STATUS_PENDING, 0);
     $n_active = idx($counts, DrydockResourceStatus::STATUS_ACTIVE, 0);
     $n_broken = idx($counts, DrydockResourceStatus::STATUS_BROKEN, 0);
     $n_released = idx($counts, DrydockResourceStatus::STATUS_RELEASED, 0);
 
     // If we're at the limit on total active resources, limit additional
     // allocations.
     $n_total = ($n_alloc + $n_active + $n_broken + $n_released);
     if ($n_total >= $total_limit) {
       return true;
     }
 
     return false;
   }
 
 }
diff --git a/src/applications/drydock/storage/DrydockAuthorization.php b/src/applications/drydock/storage/DrydockAuthorization.php
index 32e694918c..71f8c36cf8 100644
--- a/src/applications/drydock/storage/DrydockAuthorization.php
+++ b/src/applications/drydock/storage/DrydockAuthorization.php
@@ -1,256 +1,256 @@
 <?php
 
 final class DrydockAuthorization extends DrydockDAO
   implements
     PhabricatorPolicyInterface,
     PhabricatorConduitResultInterface {
 
   const OBJECTAUTH_ACTIVE = 'active';
   const OBJECTAUTH_INACTIVE = 'inactive';
 
   const BLUEPRINTAUTH_REQUESTED = 'requested';
   const BLUEPRINTAUTH_AUTHORIZED = 'authorized';
   const BLUEPRINTAUTH_DECLINED = 'declined';
 
   protected $blueprintPHID;
   protected $blueprintAuthorizationState;
   protected $objectPHID;
   protected $objectAuthorizationState;
 
   private $blueprint = self::ATTACHABLE;
   private $object = self::ATTACHABLE;
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_COLUMN_SCHEMA => array(
         'blueprintAuthorizationState' => 'text32',
         'objectAuthorizationState' => 'text32',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_unique' => array(
           'columns' => array('objectPHID', 'blueprintPHID'),
           'unique' => true,
         ),
         'key_blueprint' => array(
           'columns' => array('blueprintPHID', 'blueprintAuthorizationState'),
         ),
         'key_object' => array(
           'columns' => array('objectPHID', 'objectAuthorizationState'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function generatePHID() {
     return PhabricatorPHID::generateNewPHID(
       DrydockAuthorizationPHIDType::TYPECONST);
   }
 
   public function attachBlueprint(DrydockBlueprint $blueprint) {
     $this->blueprint = $blueprint;
     return $this;
   }
 
   public function getBlueprint() {
     return $this->assertAttached($this->blueprint);
   }
 
   public function attachObject($object) {
     $this->object = $object;
     return $this;
   }
 
   public function getObject() {
     return $this->assertAttached($this->object);
   }
 
   public static function getBlueprintStateIcon($state) {
     $map = array(
       self::BLUEPRINTAUTH_REQUESTED => 'fa-exclamation-circle pink',
       self::BLUEPRINTAUTH_AUTHORIZED => 'fa-check-circle green',
       self::BLUEPRINTAUTH_DECLINED => 'fa-times red',
     );
 
     return idx($map, $state, null);
   }
 
   public static function getBlueprintStateName($state) {
     $map = array(
       self::BLUEPRINTAUTH_REQUESTED => pht('Requested'),
       self::BLUEPRINTAUTH_AUTHORIZED => pht('Authorized'),
       self::BLUEPRINTAUTH_DECLINED => pht('Declined'),
     );
 
     return idx($map, $state, pht('<Unknown: %s>', $state));
   }
 
   public static function getObjectStateName($state) {
     $map = array(
       self::OBJECTAUTH_ACTIVE => pht('Active'),
       self::OBJECTAUTH_INACTIVE => pht('Inactive'),
     );
 
     return idx($map, $state, pht('<Unknown: %s>', $state));
   }
 
   public function isAuthorized() {
     $state = $this->getBlueprintAuthorizationState();
     return ($state == self::BLUEPRINTAUTH_AUTHORIZED);
   }
 
   /**
    * Apply external authorization effects after a user changes the value of a
    * blueprint selector control an object.
    *
-   * @param PhabricatorUser User applying the change.
-   * @param phid Object PHID change is being applied to.
-   * @param list<phid> Old blueprint PHIDs.
-   * @param list<phid> New blueprint PHIDs.
+   * @param PhabricatorUser $viewer User applying the change.
+   * @param phid $object_phid Object PHID change is being applied to.
+   * @param list<phid> $old Old blueprint PHIDs.
+   * @param list<phid> $new New blueprint PHIDs.
    * @return void
    */
   public static function applyAuthorizationChanges(
     PhabricatorUser $viewer,
     $object_phid,
     array $old,
     array $new) {
 
     $old_phids = array_fuse($old);
     $new_phids = array_fuse($new);
 
     $rem_phids = array_diff_key($old_phids, $new_phids);
     $add_phids = array_diff_key($new_phids, $old_phids);
 
     $altered_phids = $rem_phids + $add_phids;
 
     if (!$altered_phids) {
       return;
     }
 
     $authorizations = id(new DrydockAuthorizationQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withObjectPHIDs(array($object_phid))
       ->withBlueprintPHIDs($altered_phids)
       ->execute();
     $authorizations = mpull($authorizations, null, 'getBlueprintPHID');
 
     $state_active = self::OBJECTAUTH_ACTIVE;
     $state_inactive = self::OBJECTAUTH_INACTIVE;
 
     $state_requested = self::BLUEPRINTAUTH_REQUESTED;
 
     // Disable the object side of the authorization for any existing
     // authorizations.
     foreach ($rem_phids as $rem_phid) {
       $authorization = idx($authorizations, $rem_phid);
       if (!$authorization) {
         continue;
       }
 
       $authorization
         ->setObjectAuthorizationState($state_inactive)
         ->save();
     }
 
     // For new authorizations, either add them or reactivate them depending
     // on the current state.
     foreach ($add_phids as $add_phid) {
       $needs_update = false;
 
       $authorization = idx($authorizations, $add_phid);
       if (!$authorization) {
         $authorization = id(new DrydockAuthorization())
           ->setObjectPHID($object_phid)
           ->setObjectAuthorizationState($state_active)
           ->setBlueprintPHID($add_phid)
           ->setBlueprintAuthorizationState($state_requested);
 
         $needs_update = true;
       } else {
         $current_state = $authorization->getObjectAuthorizationState();
         if ($current_state != $state_active) {
           $authorization->setObjectAuthorizationState($state_active);
           $needs_update = true;
         }
       }
 
       if ($needs_update) {
         $authorization->save();
       }
     }
   }
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     return $this->getBlueprint()->getPolicy($capability);
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return $this->getBlueprint()->hasAutomaticCapability($capability, $viewer);
   }
 
   public function describeAutomaticCapability($capability) {
     return pht(
       'An authorization inherits the policies of the blueprint it '.
       'authorizes access to.');
   }
 
 
 /* -(  PhabricatorConduitResultInterface  )---------------------------------- */
 
 
   public function getFieldSpecificationsForConduit() {
     return array(
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('blueprintPHID')
         ->setType('phid')
         ->setDescription(pht(
           'PHID of the blueprint this request was made for.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('blueprintAuthorizationState')
         ->setType('map<string, wild>')
         ->setDescription(pht('Authorization state of this request.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('objectPHID')
         ->setType('phid')
         ->setDescription(pht(
           'PHID of the object which requested authorization.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('objectAuthorizationState')
         ->setType('map<string, wild>')
         ->setDescription(pht('Authorization state of the requesting object.')),
     );
   }
 
   public function getFieldValuesForConduit() {
     $blueprint_state = $this->getBlueprintAuthorizationState();
     $object_state = $this->getObjectAuthorizationState();
     return array(
       'blueprintPHID' => $this->getBlueprintPHID(),
       'blueprintAuthorizationState' => array(
         'value' => $blueprint_state,
         'name' => self::getBlueprintStateName($blueprint_state),
       ),
       'objectPHID' => $this->getObjectPHID(),
       'objectAuthorizationState' => array(
         'value' => $object_state,
         'name' => self::getObjectStateName($object_state),
       ),
     );
   }
 
   public function getConduitSearchAttachments() {
     return array(
     );
   }
 
 }
diff --git a/src/applications/drydock/storage/DrydockSlotLock.php b/src/applications/drydock/storage/DrydockSlotLock.php
index 7a9bce8b3f..3ba5163342 100644
--- a/src/applications/drydock/storage/DrydockSlotLock.php
+++ b/src/applications/drydock/storage/DrydockSlotLock.php
@@ -1,176 +1,176 @@
 <?php
 
 /**
  * Simple optimistic locks for Drydock resources and leases.
  *
  * Most blueprints only need very simple locks: for example, a host blueprint
  * might not want to create multiple resources representing the same physical
  * machine. These optimistic "slot locks" provide a flexible way to do this
  * sort of simple locking.
  *
  * @task info Getting Lock Information
  * @task lock Acquiring and Releasing Locks
  */
 final class DrydockSlotLock extends DrydockDAO {
 
   protected $ownerPHID;
   protected $lockIndex;
   protected $lockKey;
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_TIMESTAMPS => false,
       self::CONFIG_COLUMN_SCHEMA => array(
         'lockIndex' => 'bytes12',
         'lockKey' => 'text',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_lock' => array(
           'columns' => array('lockIndex'),
           'unique' => true,
         ),
         'key_owner' => array(
           'columns' => array('ownerPHID'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
 
 /* -(  Getting Lock Information  )------------------------------------------- */
 
 
   /**
    * Load all locks held by a particular owner.
    *
-   * @param phid Owner PHID.
+   * @param phid $owner_phid Owner PHID.
    * @return list<DrydockSlotLock> All held locks.
    * @task info
    */
   public static function loadLocks($owner_phid) {
     return id(new DrydockSlotLock())->loadAllWhere(
       'ownerPHID = %s',
       $owner_phid);
   }
 
 
   /**
    * Test if a lock is currently free.
    *
-   * @param string Lock key to test.
+   * @param string $lock Lock key to test.
    * @return bool True if the lock is currently free.
    * @task info
    */
   public static function isLockFree($lock) {
     return self::areLocksFree(array($lock));
   }
 
 
   /**
    * Test if a list of locks are all currently free.
    *
-   * @param list<string> List of lock keys to test.
+   * @param list<string> $locks List of lock keys to test.
    * @return bool True if all locks are currently free.
    * @task info
    */
   public static function areLocksFree(array $locks) {
     $lock_map = self::loadHeldLocks($locks);
     return !$lock_map;
   }
 
 
   /**
    * Load named locks.
    *
-   * @param list<string> List of lock keys to load.
+   * @param list<string> $locks List of lock keys to load.
    * @return list<DrydockSlotLock> List of held locks.
    * @task info
    */
   public static function loadHeldLocks(array $locks) {
     if (!$locks) {
       return array();
     }
 
     $table = new DrydockSlotLock();
     $conn_r = $table->establishConnection('r');
 
     $indexes = array();
     foreach ($locks as $lock) {
       $indexes[] = PhabricatorHash::digestForIndex($lock);
     }
 
     return id(new DrydockSlotLock())->loadAllWhere(
       'lockIndex IN (%Ls)',
       $indexes);
   }
 
 
 /* -(  Acquiring and Releasing Locks  )-------------------------------------- */
 
 
   /**
    * Acquire a set of slot locks.
    *
    * This method either acquires all the locks or throws an exception (usually
    * because one or more locks are held).
    *
-   * @param phid Lock owner PHID.
-   * @param list<string> List of locks to acquire.
+   * @param phid $owner_phid Lock owner PHID.
+   * @param list<string> $locks List of locks to acquire.
    * @return void
    * @task locks
    */
   public static function acquireLocks($owner_phid, array $locks) {
     if (!$locks) {
       return;
     }
 
     $table = new DrydockSlotLock();
     $conn_w = $table->establishConnection('w');
 
     $sql = array();
     foreach ($locks as $lock) {
       $sql[] = qsprintf(
         $conn_w,
         '(%s, %s, %s)',
         $owner_phid,
         PhabricatorHash::digestForIndex($lock),
         $lock);
     }
 
     try {
       queryfx(
         $conn_w,
         'INSERT INTO %T (ownerPHID, lockIndex, lockKey) VALUES %LQ',
         $table->getTableName(),
         $sql);
     } catch (AphrontDuplicateKeyQueryException $ex) {
       // Try to improve the readability of the exception. We might miss on
       // this query if the lock has already been released, but most of the
       // time we should be able to figure out which locks are already held.
       $held = self::loadHeldLocks($locks);
       $held = mpull($held, 'getOwnerPHID', 'getLockKey');
 
       throw new DrydockSlotLockException($held);
     }
   }
 
 
   /**
    * Release all locks held by an owner.
    *
-   * @param phid Lock owner PHID.
+   * @param phid $owner_phid Lock owner PHID.
    * @return void
    * @task locks
    */
   public static function releaseLocks($owner_phid) {
     $table = new DrydockSlotLock();
     $conn_w = $table->establishConnection('w');
 
     queryfx(
       $conn_w,
       'DELETE FROM %T WHERE ownerPHID = %s',
       $table->getTableName(),
       $owner_phid);
   }
 
 }
diff --git a/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php b/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php
index 0402276696..54a44660ec 100644
--- a/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php
+++ b/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php
@@ -1,1129 +1,1131 @@
 <?php
 
 /**
  * @task update Updating Leases
  * @task command Processing Commands
  * @task allocator Drydock Allocator
  * @task acquire Acquiring Leases
  * @task activate Activating Leases
  * @task release Releasing Leases
  * @task break Breaking Leases
  * @task destroy Destroying Leases
  */
 final class DrydockLeaseUpdateWorker extends DrydockWorker {
 
   protected function doWork() {
     $lease_phid = $this->getTaskDataValue('leasePHID');
 
     $hash = PhabricatorHash::digestForIndex($lease_phid);
     $lock_key = 'drydock.lease:'.$hash;
 
     $lock = PhabricatorGlobalLock::newLock($lock_key)
       ->lock(1);
 
     try {
       $lease = $this->loadLease($lease_phid);
       $this->handleUpdate($lease);
     } catch (Exception $ex) {
       $lock->unlock();
       $this->flushDrydockTaskQueue();
       throw $ex;
     }
 
     $lock->unlock();
   }
 
 
 /* -(  Updating Leases  )---------------------------------------------------- */
 
 
   /**
    * @task update
    */
   private function handleUpdate(DrydockLease $lease) {
     try {
       $this->updateLease($lease);
     } catch (DrydockAcquiredBrokenResourceException $ex) {
       // If this lease acquired a resource but failed to activate, we don't
       // need to break the lease. We can throw it back in the pool and let
       // it take another shot at acquiring a new resource.
 
       // Before we throw it back, release any locks the lease is holding.
       DrydockSlotLock::releaseLocks($lease->getPHID());
 
       $lease
         ->setStatus(DrydockLeaseStatus::STATUS_PENDING)
         ->setResourcePHID(null)
         ->save();
 
       $lease->logEvent(
         DrydockLeaseReacquireLogType::LOGCONST,
         array(
           'class' => get_class($ex),
           'message' => $ex->getMessage(),
         ));
 
       $this->yieldLease($lease, $ex);
     } catch (Exception $ex) {
       if ($this->isTemporaryException($ex)) {
         $this->yieldLease($lease, $ex);
       } else {
         $this->breakLease($lease, $ex);
       }
     }
   }
 
 
   /**
    * @task update
    */
   private function updateLease(DrydockLease $lease) {
     $this->processLeaseCommands($lease);
 
     $lease_status = $lease->getStatus();
     switch ($lease_status) {
       case DrydockLeaseStatus::STATUS_PENDING:
         $this->executeAllocator($lease);
         break;
       case DrydockLeaseStatus::STATUS_ACQUIRED:
         $this->activateLease($lease);
         break;
       case DrydockLeaseStatus::STATUS_ACTIVE:
         // Nothing to do.
         break;
       case DrydockLeaseStatus::STATUS_RELEASED:
       case DrydockLeaseStatus::STATUS_BROKEN:
         $this->destroyLease($lease);
         break;
       case DrydockLeaseStatus::STATUS_DESTROYED:
         break;
     }
 
     $this->yieldIfExpiringLease($lease);
   }
 
 
   /**
    * @task update
    */
   private function yieldLease(DrydockLease $lease, Exception $ex) {
     $duration = $this->getYieldDurationFromException($ex);
 
     $lease->logEvent(
       DrydockLeaseActivationYieldLogType::LOGCONST,
       array(
         'duration' => $duration,
       ));
 
     throw new PhabricatorWorkerYieldException($duration);
   }
 
 
 /* -(  Processing Commands  )------------------------------------------------ */
 
 
   /**
    * @task command
    */
   private function processLeaseCommands(DrydockLease $lease) {
     if (!$lease->canReceiveCommands()) {
       return;
     }
 
     $this->checkLeaseExpiration($lease);
 
     $commands = $this->loadCommands($lease->getPHID());
     foreach ($commands as $command) {
       if (!$lease->canReceiveCommands()) {
         break;
       }
 
       $this->processLeaseCommand($lease, $command);
 
       $command
         ->setIsConsumed(true)
         ->save();
     }
   }
 
 
   /**
    * @task command
    */
   private function processLeaseCommand(
     DrydockLease $lease,
     DrydockCommand $command) {
     switch ($command->getCommand()) {
       case DrydockCommand::COMMAND_RELEASE:
         $this->releaseLease($lease);
         break;
     }
   }
 
 
 /* -(  Drydock Allocator  )-------------------------------------------------- */
 
 
   /**
    * Find or build a resource which can satisfy a given lease request, then
    * acquire the lease.
    *
-   * @param DrydockLease Requested lease.
+   * @param DrydockLease $lease Requested lease.
    * @return void
    * @task allocator
    */
   private function executeAllocator(DrydockLease $lease) {
     $blueprints = $this->loadBlueprintsForAllocatingLease($lease);
 
     // If we get nothing back, that means no blueprint is defined which can
     // ever build the requested resource. This is a permanent failure, since
     // we don't expect to succeed no matter how many times we try.
     if (!$blueprints) {
       throw new PhabricatorWorkerPermanentFailureException(
         pht(
           'No active Drydock blueprint exists which can ever allocate a '.
           'resource for lease "%s".',
           $lease->getPHID()));
     }
 
     // First, try to find a suitable open resource which we can acquire a new
     // lease on.
     $resources = $this->loadAcquirableResourcesForLease($blueprints, $lease);
 
     list($free_resources, $used_resources) = $this->partitionResources(
       $lease,
       $resources);
 
     $resource = $this->leaseAnyResource($lease, $free_resources);
     if ($resource) {
       return $resource;
     }
 
     // We're about to try creating a resource. If we're already creating
     // something, just yield until that resolves.
 
     $this->yieldForPendingResources($lease);
 
     // We haven't been able to lease an existing resource yet, so now we try to
     // create one. We may still have some less-desirable "used" resources that
     // we'll sometimes try to lease later if we fail to allocate a new resource.
 
     $resource = $this->newLeasedResource($lease, $blueprints);
     if ($resource) {
       return $resource;
     }
 
     // We haven't been able to lease a desirable "free" resource or create a
     // new resource. Try to lease a "used" resource.
 
     $resource = $this->leaseAnyResource($lease, $used_resources);
     if ($resource) {
       return $resource;
     }
 
     // If this lease has already triggered a reclaim, just yield and wait for
     // it to resolve.
     $this->yieldForReclaimingResources($lease);
 
     // Try to reclaim a resource. This will yield if it reclaims something.
     $this->reclaimAnyResource($lease, $blueprints);
 
     // We weren't able to lease, create, or reclaim any resources. We just have
     // to wait for resources to become available.
 
     $lease->logEvent(
       DrydockLeaseWaitingForResourcesLogType::LOGCONST,
       array(
         'blueprintPHIDs' => mpull($blueprints, 'getPHID'),
       ));
 
     throw new PhabricatorWorkerYieldException(15);
   }
 
   private function reclaimAnyResource(DrydockLease $lease, array $blueprints) {
     assert_instances_of($blueprints, 'DrydockBlueprint');
 
     $blueprints = $this->rankBlueprints($blueprints, $lease);
 
     // Try to actively reclaim unused resources. If we succeed, jump back
     // into the queue in an effort to claim it.
 
     foreach ($blueprints as $blueprint) {
       $reclaimed = $this->reclaimResources($blueprint, $lease);
       if ($reclaimed) {
 
         $lease->logEvent(
           DrydockLeaseReclaimLogType::LOGCONST,
           array(
             'resourcePHIDs' => array($reclaimed->getPHID()),
           ));
 
         // Yield explicitly here: we'll be awakened when the resource is
         // reclaimed.
 
         throw new PhabricatorWorkerYieldException(15);
       }
     }
   }
 
   private function yieldForPendingResources(DrydockLease $lease) {
     // See T13677. If this lease has already triggered the allocation of
     // one or more resources and they are still pending, just yield and
     // wait for them.
 
     $viewer = $this->getViewer();
 
     $phids = $lease->getAllocatedResourcePHIDs();
     if (!$phids) {
       return null;
     }
 
     $resources = id(new DrydockResourceQuery())
       ->setViewer($viewer)
       ->withPHIDs($phids)
       ->withStatuses(
         array(
           DrydockResourceStatus::STATUS_PENDING,
         ))
       ->setLimit(1)
       ->execute();
     if (!$resources) {
       return;
     }
 
     $lease->logEvent(
       DrydockLeaseWaitingForActivationLogType::LOGCONST,
       array(
         'resourcePHIDs' => mpull($resources, 'getPHID'),
       ));
 
     throw new PhabricatorWorkerYieldException(15);
   }
 
   private function yieldForReclaimingResources(DrydockLease $lease) {
     $viewer = $this->getViewer();
 
     $phids = $lease->getReclaimedResourcePHIDs();
     if (!$phids) {
       return;
     }
 
     $resources = id(new DrydockResourceQuery())
       ->setViewer($viewer)
       ->withPHIDs($phids)
       ->withStatuses(
         array(
           DrydockResourceStatus::STATUS_ACTIVE,
           DrydockResourceStatus::STATUS_RELEASED,
         ))
       ->setLimit(1)
       ->execute();
     if (!$resources) {
       return;
     }
 
     $lease->logEvent(
       DrydockLeaseWaitingForReclamationLogType::LOGCONST,
       array(
         'resourcePHIDs' => mpull($resources, 'getPHID'),
       ));
 
     throw new PhabricatorWorkerYieldException(15);
   }
 
   private function newLeasedResource(
     DrydockLease $lease,
     array $blueprints) {
     assert_instances_of($blueprints, 'DrydockBlueprint');
 
     $usable_blueprints = $this->removeOverallocatedBlueprints(
       $blueprints,
       $lease);
 
     // If we get nothing back here, some blueprint claims it can eventually
     // satisfy the lease, just not right now. This is a temporary failure,
     // and we expect allocation to succeed eventually.
 
     // Return, try to lease a "used" resource, and continue from there.
 
     if (!$usable_blueprints) {
       return null;
     }
 
     $usable_blueprints = $this->rankBlueprints($usable_blueprints, $lease);
 
     $new_resources = $this->newResources($lease, $usable_blueprints);
     if (!$new_resources) {
       // If we were unable to create any new resources, return and
       // try to lease a "used" resource.
       return null;
     }
 
     $new_resources = $this->removeUnacquirableResources(
       $new_resources,
       $lease);
     if (!$new_resources) {
       // If we make it here, we just built a resource but aren't allowed
       // to acquire it. We expect this to happen if the resource prevents
       // acquisition until it activates, which is common when a resource
       // needs to perform setup steps.
 
       // Explicitly yield and wait for activation, since we don't want to
       // lease a "used" resource.
 
       throw new PhabricatorWorkerYieldException(15);
     }
 
     $resource = $this->leaseAnyResource($lease, $new_resources);
     if ($resource) {
       return $resource;
     }
 
     // We may not be able to lease a resource even if we just built it:
     // another process may snatch it up before we can lease it. This should
     // be rare, but is not concerning. Just try to build another resource.
 
     // We likely could try to build the next resource immediately, but err on
     // the side of caution and yield for now, at least until this code is
     // better vetted.
 
     throw new PhabricatorWorkerYieldException(15);
   }
 
   private function partitionResources(
     DrydockLease $lease,
     array $resources) {
 
     assert_instances_of($resources, 'DrydockResource');
     $viewer = $this->getViewer();
 
     $lease_statuses = array(
       DrydockLeaseStatus::STATUS_PENDING,
       DrydockLeaseStatus::STATUS_ACQUIRED,
       DrydockLeaseStatus::STATUS_ACTIVE,
     );
 
     // Partition resources into "free" resources (which we can try to lease
     // immediately) and "used" resources, which we can only to lease after we
     // fail to allocate a new resource.
 
     // "Free" resources are unleased and/or prefer reuse over allocation.
     // "Used" resources are leased and prefer allocation over reuse.
 
     $free_resources = array();
     $used_resources = array();
 
     foreach ($resources as $resource) {
       $blueprint = $resource->getBlueprint();
 
       if (!$blueprint->shouldAllocateSupplementalResource($resource, $lease)) {
         $free_resources[] = $resource;
         continue;
       }
 
       $leases = id(new DrydockLeaseQuery())
         ->setViewer($viewer)
         ->withResourcePHIDs(array($resource->getPHID()))
         ->withStatuses($lease_statuses)
         ->setLimit(1)
         ->execute();
       if (!$leases) {
         $free_resources[] = $resource;
         continue;
       }
 
       $used_resources[] = $resource;
     }
 
     return array($free_resources, $used_resources);
   }
 
   private function newResources(
     DrydockLease $lease,
     array $blueprints) {
     assert_instances_of($blueprints, 'DrydockBlueprint');
 
     $resources = array();
     $exceptions = array();
     foreach ($blueprints as $blueprint) {
       $caught = null;
       try {
         $resources[] = $this->allocateResource($blueprint, $lease);
 
         // Bail after allocating one resource, we don't need any more than
         // this.
         break;
       } catch (Exception $ex) {
         $caught = $ex;
       } catch (Throwable $ex) {
         $caught = $ex;
       }
 
       if ($caught) {
         // This failure is not normally expected, so log it. It can be
         // caused by something mundane and recoverable, however (see below
         // for discussion).
 
         // We log to the blueprint separately from the log to the lease:
         // the lease is not attached to a blueprint yet so the lease log
         // will not show up on the blueprint; more than one blueprint may
         // fail; and the lease is not really impacted (and won't log) if at
         // least one blueprint actually works.
 
         $blueprint->logEvent(
           DrydockResourceAllocationFailureLogType::LOGCONST,
           array(
             'class' => get_class($caught),
             'message' => $caught->getMessage(),
           ));
 
         $exceptions[] = $caught;
       }
     }
 
     if (!$resources) {
       // If one or more blueprints claimed that they would be able to allocate
       // resources but none are actually able to allocate resources, log the
       // failure and yield so we try again soon.
 
       // This can happen if some unexpected issue occurs during allocation
       // (for example, a call to build a VM fails for some reason) or if we
       // raced another allocator and the blueprint is now full.
 
       $ex = new PhutilAggregateException(
         pht(
           'All blueprints failed to allocate a suitable new resource when '.
           'trying to allocate lease ("%s").',
           $lease->getPHID()),
         $exceptions);
 
       $lease->logEvent(
         DrydockLeaseAllocationFailureLogType::LOGCONST,
         array(
           'class' => get_class($ex),
           'message' => $ex->getMessage(),
         ));
 
       return null;
     }
 
     return $resources;
   }
 
 
   private function leaseAnyResource(
     DrydockLease $lease,
     array $resources) {
     assert_instances_of($resources, 'DrydockResource');
 
     if (!$resources) {
       return null;
     }
 
     $resources = $this->rankResources($resources, $lease);
 
     $exceptions = array();
     $yields = array();
 
     $allocated = null;
     foreach ($resources as $resource) {
       try {
         $this->acquireLease($resource, $lease);
         $allocated = $resource;
         break;
       } catch (DrydockResourceLockException $ex) {
         // We need to lock the resource to actually acquire it. If we aren't
         // able to acquire the lock quickly enough, we can yield and try again
         // later.
         $yields[] = $ex;
       } catch (DrydockSlotLockException $ex) {
         // This also just indicates we ran into some kind of contention,
         // probably from another lease. Just yield.
         $yields[] = $ex;
       } catch (DrydockAcquiredBrokenResourceException $ex) {
         // If a resource was reclaimed or destroyed by the time we actually
         // got around to acquiring it, we just got unlucky.
         $yields[] = $ex;
       } catch (PhabricatorWorkerYieldException $ex) {
         // We can be told to yield, particularly by the supplemental allocator
         // trying to give us a supplemental resource.
         $yields[] = $ex;
       } catch (Exception $ex) {
         $exceptions[] = $ex;
       }
     }
 
     if ($allocated) {
       return $allocated;
     }
 
     if ($yields) {
       throw new PhabricatorWorkerYieldException(15);
     }
 
     throw new PhutilAggregateException(
       pht(
         'Unable to acquire lease "%s" on any resource.',
         $lease->getPHID()),
       $exceptions);
   }
 
 
   /**
    * Get all the concrete @{class:DrydockBlueprint}s which can possibly
    * build a resource to satisfy a lease.
    *
-   * @param DrydockLease Requested lease.
+   * @param DrydockLease $lease Requested lease.
    * @return list<DrydockBlueprint> List of qualifying blueprints.
    * @task allocator
    */
   private function loadBlueprintsForAllocatingLease(
     DrydockLease $lease) {
     $viewer = $this->getViewer();
 
     $impls = DrydockBlueprintImplementation::getAllForAllocatingLease($lease);
     if (!$impls) {
       return array();
     }
 
     $blueprint_phids = $lease->getAllowedBlueprintPHIDs();
     if (!$blueprint_phids) {
       $lease->logEvent(DrydockLeaseNoBlueprintsLogType::LOGCONST);
       return array();
     }
 
     $query = id(new DrydockBlueprintQuery())
       ->setViewer($viewer)
       ->withPHIDs($blueprint_phids)
       ->withBlueprintClasses(array_keys($impls))
       ->withDisabled(false);
 
     // The Drydock application itself is allowed to authorize anything. This
     // is primarily used for leases generated by CLI administrative tools.
     $drydock_phid = id(new PhabricatorDrydockApplication())->getPHID();
 
     $authorizing_phid = $lease->getAuthorizingPHID();
     if ($authorizing_phid != $drydock_phid) {
       $blueprints = id(clone $query)
         ->withAuthorizedPHIDs(array($authorizing_phid))
         ->execute();
       if (!$blueprints) {
         // If we didn't hit any blueprints, check if this is an authorization
         // problem: re-execute the query without the authorization constraint.
         // If the second query hits blueprints, the overall configuration is
         // fine but this is an authorization problem. If the second query also
         // comes up blank, this is some other kind of configuration issue so
         // we fall through to the default pathway.
         $all_blueprints = $query->execute();
         if ($all_blueprints) {
           $lease->logEvent(
             DrydockLeaseNoAuthorizationsLogType::LOGCONST,
             array(
               'authorizingPHID' => $authorizing_phid,
             ));
           return array();
         }
       }
     } else {
       $blueprints = $query->execute();
     }
 
     $keep = array();
     foreach ($blueprints as $key => $blueprint) {
       if (!$blueprint->canEverAllocateResourceForLease($lease)) {
         continue;
       }
 
       $keep[$key] = $blueprint;
     }
 
     return $keep;
   }
 
 
   /**
    * Load a list of all resources which a given lease can possibly be
    * allocated against.
    *
-   * @param list<DrydockBlueprint> Blueprints which may produce suitable
-   *   resources.
-   * @param DrydockLease Requested lease.
+   * @param list<DrydockBlueprint> $blueprints Blueprints which may produce
+   *   suitable resources.
+   * @param DrydockLease $lease Requested lease.
    * @return list<DrydockResource> Resources which may be able to allocate
    *   the lease.
    * @task allocator
    */
   private function loadAcquirableResourcesForLease(
     array $blueprints,
     DrydockLease $lease) {
     assert_instances_of($blueprints, 'DrydockBlueprint');
     $viewer = $this->getViewer();
 
     $resources = id(new DrydockResourceQuery())
       ->setViewer($viewer)
       ->withBlueprintPHIDs(mpull($blueprints, 'getPHID'))
       ->withTypes(array($lease->getResourceType()))
       ->withStatuses(
         array(
           DrydockResourceStatus::STATUS_ACTIVE,
         ))
       ->execute();
 
     return $this->removeUnacquirableResources($resources, $lease);
   }
 
 
   /**
    * Remove resources which can not be acquired by a given lease from a list.
    *
-   * @param list<DrydockResource> Candidate resources.
-   * @param DrydockLease Acquiring lease.
+   * @param list<DrydockResource> $resources Candidate resources.
+   * @param DrydockLease $lease Acquiring lease.
    * @return list<DrydockResource> Resources which the lease may be able to
    *   acquire.
    * @task allocator
    */
   private function removeUnacquirableResources(
     array $resources,
     DrydockLease $lease) {
     $keep = array();
     foreach ($resources as $key => $resource) {
       $blueprint = $resource->getBlueprint();
 
       if (!$blueprint->canAcquireLeaseOnResource($resource, $lease)) {
         continue;
       }
 
       $keep[$key] = $resource;
     }
 
     return $keep;
   }
 
 
   /**
    * Remove blueprints which are too heavily allocated to build a resource for
    * a lease from a list of blueprints.
    *
-   * @param list<DrydockBlueprint> List of blueprints.
-   * @return list<DrydockBlueprint> List with blueprints that can not allocate
-   *   a resource for the lease right now removed.
+   * @param list<DrydockBlueprint> $blueprints List of blueprints.
+   * @return list<DrydockBlueprint> $lease List with blueprints that can not
+   *   allocate a resource for the lease right now removed.
    * @task allocator
    */
   private function removeOverallocatedBlueprints(
     array $blueprints,
     DrydockLease $lease) {
     assert_instances_of($blueprints, 'DrydockBlueprint');
 
     $keep = array();
 
     foreach ($blueprints as $key => $blueprint) {
       if (!$blueprint->canAllocateResourceForLease($lease)) {
         continue;
       }
 
       $keep[$key] = $blueprint;
     }
 
     return $keep;
   }
 
 
   /**
    * Rank blueprints by suitability for building a new resource for a
    * particular lease.
    *
-   * @param list<DrydockBlueprint> List of blueprints.
-   * @param DrydockLease Requested lease.
+   * @param list<DrydockBlueprint> $blueprints List of blueprints.
+   * @param DrydockLease $lease Requested lease.
    * @return list<DrydockBlueprint> Ranked list of blueprints.
    * @task allocator
    */
   private function rankBlueprints(array $blueprints, DrydockLease $lease) {
     assert_instances_of($blueprints, 'DrydockBlueprint');
 
     // TODO: Implement improvements to this ranking algorithm if they become
     // available.
     shuffle($blueprints);
 
     return $blueprints;
   }
 
 
   /**
    * Rank resources by suitability for allocating a particular lease.
    *
-   * @param list<DrydockResource> List of resources.
-   * @param DrydockLease Requested lease.
+   * @param list<DrydockResource> $resources List of resources.
+   * @param DrydockLease $lease Requested lease.
    * @return list<DrydockResource> Ranked list of resources.
    * @task allocator
    */
   private function rankResources(array $resources, DrydockLease $lease) {
     assert_instances_of($resources, 'DrydockResource');
 
     // TODO: Implement improvements to this ranking algorithm if they become
     // available.
     shuffle($resources);
 
     return $resources;
   }
 
 
   /**
    * Perform an actual resource allocation with a particular blueprint.
    *
-   * @param DrydockBlueprint The blueprint to allocate a resource from.
-   * @param DrydockLease Requested lease.
+   * @param DrydockBlueprint $blueprint The blueprint to allocate a resource
+   *   from.
+   * @param DrydockLease $lease Requested lease.
    * @return DrydockResource Allocated resource.
    * @task allocator
    */
   private function allocateResource(
     DrydockBlueprint $blueprint,
     DrydockLease $lease) {
     $resource = $blueprint->allocateResource($lease);
     $this->validateAllocatedResource($blueprint, $resource, $lease);
 
     // If this resource was allocated as a pending resource, queue a task to
     // activate it.
     if ($resource->getStatus() == DrydockResourceStatus::STATUS_PENDING) {
 
       $lease->addAllocatedResourcePHIDs(
         array(
           $resource->getPHID(),
         ));
       $lease->save();
 
       PhabricatorWorker::scheduleTask(
         'DrydockResourceUpdateWorker',
         array(
           'resourcePHID' => $resource->getPHID(),
 
           // This task will generally yield while the resource activates, so
           // wake it back up once the resource comes online. Most of the time,
           // we'll be able to lease the newly activated resource.
           'awakenOnActivation' => array(
             $this->getCurrentWorkerTaskID(),
           ),
         ),
         array(
           'objectPHID' => $resource->getPHID(),
         ));
     }
 
     return $resource;
   }
 
 
   /**
    * Check that the resource a blueprint allocated is roughly the sort of
    * object we expect.
    *
-   * @param DrydockBlueprint Blueprint which built the resource.
-   * @param wild Thing which the blueprint claims is a valid resource.
-   * @param DrydockLease Lease the resource was allocated for.
+   * @param DrydockBlueprint $blueprint Blueprint which built the resource.
+   * @param wild $resource Thing which the blueprint claims is a valid
+   *   resource.
+   * @param DrydockLease $lease Lease the resource was allocated for.
    * @return void
    * @task allocator
    */
   private function validateAllocatedResource(
     DrydockBlueprint $blueprint,
     $resource,
     DrydockLease $lease) {
 
     if (!($resource instanceof DrydockResource)) {
       throw new Exception(
         pht(
           'Blueprint "%s" (of type "%s") is not properly implemented: %s must '.
           'return an object of type %s or throw, but returned something else.',
           $blueprint->getBlueprintName(),
           $blueprint->getClassName(),
           'allocateResource()',
           'DrydockResource'));
     }
 
     if (!$resource->isAllocatedResource()) {
       throw new Exception(
         pht(
           'Blueprint "%s" (of type "%s") is not properly implemented: %s '.
           'must actually allocate the resource it returns.',
           $blueprint->getBlueprintName(),
           $blueprint->getClassName(),
           'allocateResource()'));
     }
 
     $resource_type = $resource->getType();
     $lease_type = $lease->getResourceType();
 
     if ($resource_type !== $lease_type) {
       throw new Exception(
         pht(
           'Blueprint "%s" (of type "%s") is not properly implemented: it '.
           'built a resource of type "%s" to satisfy a lease requesting a '.
           'resource of type "%s".',
           $blueprint->getBlueprintName(),
           $blueprint->getClassName(),
           $resource_type,
           $lease_type));
     }
   }
 
   private function reclaimResources(
     DrydockBlueprint $blueprint,
     DrydockLease $lease) {
     $viewer = $this->getViewer();
 
     $resources = id(new DrydockResourceQuery())
       ->setViewer($viewer)
       ->withBlueprintPHIDs(array($blueprint->getPHID()))
       ->withStatuses(
         array(
           DrydockResourceStatus::STATUS_ACTIVE,
         ))
       ->execute();
 
     // TODO: We could be much smarter about this and try to release long-unused
     // resources, resources with many similar copies, old resources, resources
     // that are cheap to rebuild, etc.
     shuffle($resources);
 
     foreach ($resources as $resource) {
       if ($this->canReclaimResource($resource)) {
         $this->reclaimResource($resource, $lease);
         return $resource;
       }
     }
 
     return null;
   }
 
 
 /* -(  Acquiring Leases  )--------------------------------------------------- */
 
 
   /**
    * Perform an actual lease acquisition on a particular resource.
    *
-   * @param DrydockResource Resource to acquire a lease on.
-   * @param DrydockLease Lease to acquire.
+   * @param DrydockResource $resource Resource to acquire a lease on.
+   * @param DrydockLease $lease Lease to acquire.
    * @return void
    * @task acquire
    */
   private function acquireLease(
     DrydockResource $resource,
     DrydockLease $lease) {
 
     $blueprint = $resource->getBlueprint();
     $blueprint->acquireLease($resource, $lease);
 
     $this->validateAcquiredLease($blueprint, $resource, $lease);
 
     // If this lease has been acquired but not activated, queue a task to
     // activate it.
     if ($lease->getStatus() == DrydockLeaseStatus::STATUS_ACQUIRED) {
       $this->queueTask(
         __CLASS__,
         array(
           'leasePHID' => $lease->getPHID(),
         ),
         array(
           'objectPHID' => $lease->getPHID(),
         ));
     }
   }
 
 
   /**
    * Make sure that a lease was really acquired properly.
    *
-   * @param DrydockBlueprint Blueprint which created the resource.
-   * @param DrydockResource Resource which was acquired.
-   * @param DrydockLease The lease which was supposedly acquired.
+   * @param DrydockBlueprint $blueprint Blueprint which created the resource.
+   * @param DrydockResource $resource Resource which was acquired.
+   * @param DrydockLease $lease The lease which was supposedly acquired.
    * @return void
    * @task acquire
    */
   private function validateAcquiredLease(
     DrydockBlueprint $blueprint,
     DrydockResource $resource,
     DrydockLease $lease) {
 
     if (!$lease->isAcquiredLease()) {
       throw new Exception(
         pht(
           'Blueprint "%s" (of type "%s") is not properly implemented: it '.
           'returned from "%s" without acquiring a lease.',
           $blueprint->getBlueprintName(),
           $blueprint->getClassName(),
           'acquireLease()'));
     }
 
     $lease_phid = $lease->getResourcePHID();
     $resource_phid = $resource->getPHID();
 
     if ($lease_phid !== $resource_phid) {
       throw new Exception(
         pht(
           'Blueprint "%s" (of type "%s") is not properly implemented: it '.
           'returned from "%s" with a lease acquired on the wrong resource.',
           $blueprint->getBlueprintName(),
           $blueprint->getClassName(),
           'acquireLease()'));
     }
   }
 
 
 /* -(  Activating Leases  )-------------------------------------------------- */
 
 
   /**
    * @task activate
    */
   private function activateLease(DrydockLease $lease) {
     $resource = $lease->getResource();
     if (!$resource) {
       throw new Exception(
         pht('Trying to activate lease with no resource.'));
     }
 
     $resource_status = $resource->getStatus();
 
     if ($resource_status == DrydockResourceStatus::STATUS_PENDING) {
       throw new PhabricatorWorkerYieldException(15);
     }
 
     if ($resource_status != DrydockResourceStatus::STATUS_ACTIVE) {
       throw new DrydockAcquiredBrokenResourceException(
         pht(
           'Trying to activate lease ("%s") on a resource ("%s") in '.
           'the wrong status ("%s").',
           $lease->getPHID(),
           $resource->getPHID(),
           $resource_status));
     }
 
     // NOTE: We can race resource destruction here. Between the time we
     // performed the read above and now, the resource might have closed, so
     // we may activate leases on dead resources. At least for now, this seems
     // fine: a resource dying right before we activate a lease on it should not
     // be distinguishable from a resource dying right after we activate a lease
     // on it. We end up with an active lease on a dead resource either way, and
     // can not prevent resources dying from lightning strikes.
 
     $blueprint = $resource->getBlueprint();
     $blueprint->activateLease($resource, $lease);
     $this->validateActivatedLease($blueprint, $resource, $lease);
   }
 
   /**
    * @task activate
    */
   private function validateActivatedLease(
     DrydockBlueprint $blueprint,
     DrydockResource $resource,
     DrydockLease $lease) {
 
     if (!$lease->isActivatedLease()) {
       throw new Exception(
         pht(
           'Blueprint "%s" (of type "%s") is not properly implemented: it '.
           'returned from "%s" without activating a lease.',
           $blueprint->getBlueprintName(),
           $blueprint->getClassName(),
           'acquireLease()'));
     }
 
   }
 
 
 /* -(  Releasing Leases  )--------------------------------------------------- */
 
 
   /**
    * @task release
    */
   private function releaseLease(DrydockLease $lease) {
     $lease
       ->setStatus(DrydockLeaseStatus::STATUS_RELEASED)
       ->save();
 
     $lease->logEvent(DrydockLeaseReleasedLogType::LOGCONST);
 
     $resource = $lease->getResource();
     if ($resource) {
       $blueprint = $resource->getBlueprint();
       $blueprint->didReleaseLease($resource, $lease);
     }
 
     $this->destroyLease($lease);
   }
 
 
 /* -(  Breaking Leases  )---------------------------------------------------- */
 
 
   /**
    * @task break
    */
   protected function breakLease(DrydockLease $lease, Exception $ex) {
     switch ($lease->getStatus()) {
       case DrydockLeaseStatus::STATUS_BROKEN:
       case DrydockLeaseStatus::STATUS_RELEASED:
       case DrydockLeaseStatus::STATUS_DESTROYED:
         throw new PhutilProxyException(
           pht(
             'Unexpected failure while destroying lease ("%s").',
             $lease->getPHID()),
           $ex);
     }
 
     $lease
       ->setStatus(DrydockLeaseStatus::STATUS_BROKEN)
       ->save();
 
     $lease->logEvent(
       DrydockLeaseActivationFailureLogType::LOGCONST,
       array(
         'class' => get_class($ex),
         'message' => $ex->getMessage(),
       ));
 
     $lease->awakenTasks();
 
     $this->queueTask(
       __CLASS__,
       array(
         'leasePHID' => $lease->getPHID(),
       ),
       array(
         'objectPHID' => $lease->getPHID(),
       ));
 
     throw new PhabricatorWorkerPermanentFailureException(
       pht(
         'Permanent failure while activating lease ("%s"): %s',
         $lease->getPHID(),
         $ex->getMessage()));
   }
 
 
 /* -(  Destroying Leases  )-------------------------------------------------- */
 
 
   /**
    * @task destroy
    */
   private function destroyLease(DrydockLease $lease) {
     $resource = $lease->getResource();
 
     if ($resource) {
       $blueprint = $resource->getBlueprint();
       $blueprint->destroyLease($resource, $lease);
     }
 
     DrydockSlotLock::releaseLocks($lease->getPHID());
 
     $lease
       ->setStatus(DrydockLeaseStatus::STATUS_DESTROYED)
       ->save();
 
     $lease->logEvent(DrydockLeaseDestroyedLogType::LOGCONST);
 
     $lease->awakenTasks();
   }
 
 }
diff --git a/src/applications/drydock/worker/DrydockResourceUpdateWorker.php b/src/applications/drydock/worker/DrydockResourceUpdateWorker.php
index 14ef8e4936..6316116c53 100644
--- a/src/applications/drydock/worker/DrydockResourceUpdateWorker.php
+++ b/src/applications/drydock/worker/DrydockResourceUpdateWorker.php
@@ -1,313 +1,313 @@
 <?php
 
 /**
  * @task update Updating Resources
  * @task command Processing Commands
  * @task activate Activating Resources
  * @task release Releasing Resources
  * @task break Breaking Resources
  * @task destroy Destroying Resources
  */
 final class DrydockResourceUpdateWorker extends DrydockWorker {
 
   protected function doWork() {
     $resource_phid = $this->getTaskDataValue('resourcePHID');
 
     $hash = PhabricatorHash::digestForIndex($resource_phid);
     $lock_key = 'drydock.resource:'.$hash;
 
     $lock = PhabricatorGlobalLock::newLock($lock_key)
       ->lock(1);
 
     try {
       $resource = $this->loadResource($resource_phid);
       $this->handleUpdate($resource);
     } catch (Exception $ex) {
       $lock->unlock();
       $this->flushDrydockTaskQueue();
       throw $ex;
     }
 
     $lock->unlock();
   }
 
 
 /* -(  Updating Resources  )------------------------------------------------- */
 
 
   /**
    * Update a resource, handling exceptions thrown during the update.
    *
-   * @param DrydockReosource Resource to update.
+   * @param DrydockResource $resource Resource to update.
    * @return void
    * @task update
    */
   private function handleUpdate(DrydockResource $resource) {
     try {
       $this->updateResource($resource);
     } catch (Exception $ex) {
       if ($this->isTemporaryException($ex)) {
         $this->yieldResource($resource, $ex);
       } else {
         $this->breakResource($resource, $ex);
       }
     }
   }
 
 
   /**
    * Update a resource.
    *
-   * @param DrydockResource Resource to update.
+   * @param DrydockResource $resource Resource to update.
    * @return void
    * @task update
    */
   private function updateResource(DrydockResource $resource) {
     $this->processResourceCommands($resource);
 
     $resource_status = $resource->getStatus();
     switch ($resource_status) {
       case DrydockResourceStatus::STATUS_PENDING:
         $this->activateResource($resource);
         break;
       case DrydockResourceStatus::STATUS_ACTIVE:
         // Nothing to do.
         break;
       case DrydockResourceStatus::STATUS_RELEASED:
       case DrydockResourceStatus::STATUS_BROKEN:
         $this->destroyResource($resource);
         break;
       case DrydockResourceStatus::STATUS_DESTROYED:
         // Nothing to do.
         break;
     }
 
     $this->yieldIfExpiringResource($resource);
   }
 
 
   /**
    * Convert a temporary exception into a yield.
    *
-   * @param DrydockResource Resource to yield.
-   * @param Exception Temporary exception worker encountered.
+   * @param DrydockResource $resource Resource to yield.
+   * @param Exception $ex Temporary exception worker encountered.
    * @task update
    */
   private function yieldResource(DrydockResource $resource, Exception $ex) {
     $duration = $this->getYieldDurationFromException($ex);
 
     $resource->logEvent(
       DrydockResourceActivationYieldLogType::LOGCONST,
       array(
         'duration' => $duration,
       ));
 
     throw new PhabricatorWorkerYieldException($duration);
   }
 
 
 /* -(  Processing Commands  )------------------------------------------------ */
 
 
   /**
    * @task command
    */
   private function processResourceCommands(DrydockResource $resource) {
     if (!$resource->canReceiveCommands()) {
       return;
     }
 
     $this->checkResourceExpiration($resource);
 
     $commands = $this->loadCommands($resource->getPHID());
     foreach ($commands as $command) {
       if (!$resource->canReceiveCommands()) {
         break;
       }
 
       $this->processResourceCommand($resource, $command);
 
       $command
         ->setIsConsumed(true)
         ->save();
     }
   }
 
 
   /**
    * @task command
    */
   private function processResourceCommand(
     DrydockResource $resource,
     DrydockCommand $command) {
 
     switch ($command->getCommand()) {
       case DrydockCommand::COMMAND_RELEASE:
         $this->releaseResource($resource, null);
         break;
       case DrydockCommand::COMMAND_RECLAIM:
         $reclaimer_phid = $command->getAuthorPHID();
         $this->releaseResource($resource, $reclaimer_phid);
         break;
     }
 
     // If the command specifies that other worker tasks should be awakened
     // after it executes, awaken them now.
     $awaken_ids = $command->getProperty('awakenTaskIDs');
     if (is_array($awaken_ids) && $awaken_ids) {
       PhabricatorWorker::awakenTaskIDs($awaken_ids);
     }
   }
 
 
 /* -(  Activating Resources  )----------------------------------------------- */
 
 
   /**
    * @task activate
    */
   private function activateResource(DrydockResource $resource) {
     $blueprint = $resource->getBlueprint();
     $blueprint->activateResource($resource);
     $this->validateActivatedResource($blueprint, $resource);
 
     $awaken_ids = $this->getTaskDataValue('awakenOnActivation');
     if (is_array($awaken_ids) && $awaken_ids) {
       PhabricatorWorker::awakenTaskIDs($awaken_ids);
     }
   }
 
 
   /**
    * @task activate
    */
   private function validateActivatedResource(
     DrydockBlueprint $blueprint,
     DrydockResource $resource) {
 
     if (!$resource->isActivatedResource()) {
       throw new Exception(
         pht(
           'Blueprint "%s" (of type "%s") is not properly implemented: %s '.
           'must actually allocate the resource it returns.',
           $blueprint->getBlueprintName(),
           $blueprint->getClassName(),
           'allocateResource()'));
     }
 
   }
 
 
 /* -(  Releasing Resources  )------------------------------------------------ */
 
 
   /**
    * @task release
    */
   private function releaseResource(
     DrydockResource $resource,
     $reclaimer_phid) {
 
     if ($reclaimer_phid) {
       if (!$this->canReclaimResource($resource)) {
         return;
       }
 
       $resource->logEvent(
         DrydockResourceReclaimLogType::LOGCONST,
         array(
           'reclaimerPHID' => $reclaimer_phid,
         ));
     }
 
     $viewer = $this->getViewer();
     $drydock_phid = id(new PhabricatorDrydockApplication())->getPHID();
 
     $resource
       ->setStatus(DrydockResourceStatus::STATUS_RELEASED)
       ->save();
 
     $statuses = array(
       DrydockLeaseStatus::STATUS_PENDING,
       DrydockLeaseStatus::STATUS_ACQUIRED,
       DrydockLeaseStatus::STATUS_ACTIVE,
     );
 
     $leases = id(new DrydockLeaseQuery())
       ->setViewer($viewer)
       ->withResourcePHIDs(array($resource->getPHID()))
       ->withStatuses($statuses)
       ->execute();
 
     foreach ($leases as $lease) {
       $command = DrydockCommand::initializeNewCommand($viewer)
         ->setTargetPHID($lease->getPHID())
         ->setAuthorPHID($drydock_phid)
         ->setCommand(DrydockCommand::COMMAND_RELEASE)
         ->save();
 
       $lease->scheduleUpdate();
     }
 
     $this->destroyResource($resource);
   }
 
 
 /* -(  Breaking Resources  )------------------------------------------------- */
 
 
   /**
    * @task break
    */
   private function breakResource(DrydockResource $resource, Exception $ex) {
     switch ($resource->getStatus()) {
       case DrydockResourceStatus::STATUS_BROKEN:
       case DrydockResourceStatus::STATUS_RELEASED:
       case DrydockResourceStatus::STATUS_DESTROYED:
         // If the resource was already broken, just throw a normal exception.
         // This will retry the task eventually.
         throw new PhutilProxyException(
           pht(
             'Unexpected failure while destroying resource ("%s").',
             $resource->getPHID()),
           $ex);
     }
 
     $resource
       ->setStatus(DrydockResourceStatus::STATUS_BROKEN)
       ->save();
 
     $resource->scheduleUpdate();
 
     $resource->logEvent(
       DrydockResourceActivationFailureLogType::LOGCONST,
       array(
         'class' => get_class($ex),
         'message' => $ex->getMessage(),
       ));
 
     throw new PhabricatorWorkerPermanentFailureException(
       pht(
         'Permanent failure while activating resource ("%s"): %s',
         $resource->getPHID(),
         $ex->getMessage()));
   }
 
 
 /* -(  Destroying Resources  )----------------------------------------------- */
 
 
   /**
    * @task destroy
    */
   private function destroyResource(DrydockResource $resource) {
     $blueprint = $resource->getBlueprint();
     $blueprint->destroyResource($resource);
 
     DrydockSlotLock::releaseLocks($resource->getPHID());
 
     $resource
       ->setStatus(DrydockResourceStatus::STATUS_DESTROYED)
       ->save();
   }
 }
diff --git a/src/applications/feed/PhabricatorFeedStoryPublisher.php b/src/applications/feed/PhabricatorFeedStoryPublisher.php
index 70d9a2f61c..81c3dceef0 100644
--- a/src/applications/feed/PhabricatorFeedStoryPublisher.php
+++ b/src/applications/feed/PhabricatorFeedStoryPublisher.php
@@ -1,341 +1,341 @@
 <?php
 
 final class PhabricatorFeedStoryPublisher extends Phobject {
 
   private $relatedPHIDs;
   private $storyType;
   private $storyData;
   private $storyTime;
   private $storyAuthorPHID;
   private $primaryObjectPHID;
   private $subscribedPHIDs = array();
   private $mailRecipientPHIDs = array();
   private $notifyAuthor;
   private $mailTags = array();
   private $unexpandablePHIDs = array();
 
   public function setMailTags(array $mail_tags) {
     $this->mailTags = $mail_tags;
     return $this;
   }
 
   public function getMailTags() {
     return $this->mailTags;
   }
 
   public function setNotifyAuthor($notify_author) {
     $this->notifyAuthor = $notify_author;
     return $this;
   }
 
   public function getNotifyAuthor() {
     return $this->notifyAuthor;
   }
 
   public function setRelatedPHIDs(array $phids) {
     $this->relatedPHIDs = $phids;
     return $this;
   }
 
   public function setSubscribedPHIDs(array $phids) {
     $this->subscribedPHIDs = $phids;
     return $this;
   }
 
   public function setPrimaryObjectPHID($phid) {
     $this->primaryObjectPHID = $phid;
     return $this;
   }
 
   public function setUnexpandablePHIDs(array $unexpandable_phids) {
     $this->unexpandablePHIDs = $unexpandable_phids;
     return $this;
   }
 
   public function getUnexpandablePHIDs() {
     return $this->unexpandablePHIDs;
   }
 
   public function setStoryType($story_type) {
     $this->storyType = $story_type;
     return $this;
   }
 
   public function setStoryData(array $data) {
     $this->storyData = $data;
     return $this;
   }
 
   public function setStoryTime($time) {
     $this->storyTime = $time;
     return $this;
   }
 
   public function setStoryAuthorPHID($phid) {
     $this->storyAuthorPHID = $phid;
     return $this;
   }
 
   public function setMailRecipientPHIDs(array $phids) {
     $this->mailRecipientPHIDs = $phids;
     return $this;
   }
 
   public function publish() {
     $class = $this->storyType;
     if (!$class) {
       throw new Exception(
         pht(
           'Call %s before publishing!',
           'setStoryType()'));
     }
 
     if (!class_exists($class)) {
       throw new Exception(
         pht(
           "Story type must be a valid class name and must subclass %s. ".
           "'%s' is not a loadable class.",
           'PhabricatorFeedStory',
           $class));
     }
 
     if (!is_subclass_of($class, 'PhabricatorFeedStory')) {
       throw new Exception(
         pht(
           "Story type must be a valid class name and must subclass %s. ".
           "'%s' is not a subclass of %s.",
           'PhabricatorFeedStory',
           $class,
           'PhabricatorFeedStory'));
     }
 
     $chrono_key = $this->generateChronologicalKey();
 
     $story = new PhabricatorFeedStoryData();
     $story->setStoryType($this->storyType);
     $story->setStoryData($this->storyData);
     $story->setAuthorPHID((string)$this->storyAuthorPHID);
     $story->setChronologicalKey($chrono_key);
     $story->save();
 
     if ($this->relatedPHIDs) {
       $ref = new PhabricatorFeedStoryReference();
 
       $sql = array();
       $conn = $ref->establishConnection('w');
       foreach (array_unique($this->relatedPHIDs) as $phid) {
         $sql[] = qsprintf(
           $conn,
           '(%s, %s)',
           $phid,
           $chrono_key);
       }
 
       queryfx(
         $conn,
         'INSERT INTO %T (objectPHID, chronologicalKey) VALUES %LQ',
         $ref->getTableName(),
         $sql);
     }
 
     $subscribed_phids = $this->subscribedPHIDs;
     if ($subscribed_phids) {
       $subscribed_phids = $this->filterSubscribedPHIDs($subscribed_phids);
       $this->insertNotifications($chrono_key, $subscribed_phids);
       $this->sendNotification($chrono_key, $subscribed_phids);
     }
 
     PhabricatorWorker::scheduleTask(
       'FeedPublisherWorker',
       array(
         'key' => $chrono_key,
       ));
 
     return $story;
   }
 
   private function insertNotifications($chrono_key, array $subscribed_phids) {
     if (!$this->primaryObjectPHID) {
       throw new Exception(
         pht(
           'You must call %s if you %s!',
           'setPrimaryObjectPHID()',
           'setSubscribedPHIDs()'));
     }
 
     $notif = new PhabricatorFeedStoryNotification();
     $sql = array();
     $conn = $notif->establishConnection('w');
 
     $will_receive_mail = array_fill_keys($this->mailRecipientPHIDs, true);
 
     $user_phids = array_unique($subscribed_phids);
     foreach ($user_phids as $user_phid) {
       if (isset($will_receive_mail[$user_phid])) {
         $mark_read = 1;
       } else {
         $mark_read = 0;
       }
 
       $sql[] = qsprintf(
         $conn,
         '(%s, %s, %s, %d)',
         $this->primaryObjectPHID,
         $user_phid,
         $chrono_key,
         $mark_read);
     }
 
     if ($sql) {
       queryfx(
         $conn,
         'INSERT INTO %T '.
         '(primaryObjectPHID, userPHID, chronologicalKey, hasViewed) '.
         'VALUES %LQ',
         $notif->getTableName(),
         $sql);
     }
 
     PhabricatorUserCache::clearCaches(
       PhabricatorUserNotificationCountCacheType::KEY_COUNT,
       $user_phids);
   }
 
   private function sendNotification($chrono_key, array $subscribed_phids) {
     $data = array(
       'key'         => (string)$chrono_key,
       'type'        => 'notification',
       'subscribers' => $subscribed_phids,
     );
 
     PhabricatorNotificationClient::tryToPostMessage($data);
   }
 
   /**
    * Remove PHIDs who should not receive notifications from a subscriber list.
    *
-   * @param list<phid> List of potential subscribers.
+   * @param list<phid> $phids List of potential subscribers.
    * @return list<phid> List of actual subscribers.
    */
   private function filterSubscribedPHIDs(array $phids) {
     $phids = $this->expandRecipients($phids);
 
     $tags = $this->getMailTags();
     if ($tags) {
       $all_prefs = id(new PhabricatorUserPreferencesQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withUserPHIDs($phids)
         ->needSyntheticPreferences(true)
         ->execute();
       $all_prefs = mpull($all_prefs, null, 'getUserPHID');
     }
 
     $pref_default = PhabricatorEmailTagsSetting::VALUE_EMAIL;
     $pref_ignore = PhabricatorEmailTagsSetting::VALUE_IGNORE;
 
     $keep = array();
     foreach ($phids as $phid) {
       if (($phid == $this->storyAuthorPHID) && !$this->getNotifyAuthor()) {
         continue;
       }
 
       if ($tags && isset($all_prefs[$phid])) {
         $mailtags = $all_prefs[$phid]->getSettingValue(
           PhabricatorEmailTagsSetting::SETTINGKEY);
 
         $notify = false;
         foreach ($tags as $tag) {
           // If this is set to "email" or "notify", notify the user.
           if ((int)idx($mailtags, $tag, $pref_default) != $pref_ignore) {
             $notify = true;
             break;
           }
         }
 
         if (!$notify) {
           continue;
         }
       }
 
       $keep[] = $phid;
     }
 
     return array_values(array_unique($keep));
   }
 
   private function expandRecipients(array $phids) {
     $expanded_phids = id(new PhabricatorMetaMTAMemberQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withPHIDs($phids)
       ->executeExpansion();
 
     // Filter out unexpandable PHIDs from the results. The typical case for
     // this is that resigned reviewers should not be notified just because
     // they are a member of some project or package reviewer.
 
     $original_map = array_fuse($phids);
     $unexpandable_map = array_fuse($this->unexpandablePHIDs);
 
     foreach ($expanded_phids as $key => $phid) {
       // We can keep this expanded PHID if it was present originally.
       if (isset($original_map[$phid])) {
         continue;
       }
 
       // We can also keep it if it isn't marked as unexpandable.
       if (!isset($unexpandable_map[$phid])) {
         continue;
       }
 
       // If it's unexpandable and we produced it by expanding recipients,
       // throw it away.
       unset($expanded_phids[$key]);
     }
     $expanded_phids = array_values($expanded_phids);
 
     return $expanded_phids;
   }
 
   /**
    * We generate a unique chronological key for each story type because we want
    * to be able to page through the stream with a cursor (i.e., select stories
    * after ID = X) so we can efficiently perform filtering after selecting data,
    * and multiple stories with the same ID make this cumbersome without putting
    * a bunch of logic in the client. We could use the primary key, but that
    * would prevent publishing stories which happened in the past. Since it's
    * potentially useful to do that (e.g., if you're importing another data
    * source) build a unique key for each story which has chronological ordering.
    *
    * @return string A unique, time-ordered key which identifies the story.
    */
   private function generateChronologicalKey() {
     // Use the epoch timestamp for the upper 32 bits of the key. Default to
     // the current time if the story doesn't have an explicit timestamp.
     $time = nonempty($this->storyTime, time());
 
     // Generate a random number for the lower 32 bits of the key.
     $rand = head(unpack('L', Filesystem::readRandomBytes(4)));
 
     // On 32-bit machines, we have to get creative.
     if (PHP_INT_SIZE < 8) {
       // We're on a 32-bit machine.
       if (function_exists('bcadd')) {
         // Try to use the 'bc' extension.
         return bcadd(bcmul($time, bcpow(2, 32)), $rand);
       } else {
         // Do the math in MySQL. TODO: If we formalize a bc dependency, get
         // rid of this.
         $conn_r = id(new PhabricatorFeedStoryData())->establishConnection('r');
         $result = queryfx_one(
           $conn_r,
           'SELECT (%d << 32) + %d as N',
           $time,
           $rand);
         return $result['N'];
       }
     } else {
       // This is a 64 bit machine, so we can just do the math.
       return ($time << 32) + $rand;
     }
   }
 }
diff --git a/src/applications/feed/story/PhabricatorFeedStory.php b/src/applications/feed/story/PhabricatorFeedStory.php
index e0c65c7dc2..2d0ed8836b 100644
--- a/src/applications/feed/story/PhabricatorFeedStory.php
+++ b/src/applications/feed/story/PhabricatorFeedStory.php
@@ -1,491 +1,492 @@
 <?php
 
 /**
  * Manages rendering and aggregation of a story. A story is an event (like a
  * user adding a comment) which may be represented in different forms on
  * different channels (like feed, notifications and realtime alerts).
  *
  * @task load     Loading Stories
  * @task policy   Policy Implementation
  */
 abstract class PhabricatorFeedStory
   extends Phobject
   implements
     PhabricatorPolicyInterface,
     PhabricatorMarkupInterface {
 
   private $data;
   private $hasViewed;
   private $hovercard = false;
   private $renderingTarget = PhabricatorApplicationTransaction::TARGET_HTML;
 
   private $handles = array();
   private $objects = array();
   private $projectPHIDs = array();
   private $markupFieldOutput = array();
 
 /* -(  Loading Stories  )---------------------------------------------------- */
 
 
   /**
    * Given @{class:PhabricatorFeedStoryData} rows, load them into objects and
    * construct appropriate @{class:PhabricatorFeedStory} wrappers for each
    * data row.
    *
-   * @param list<dict>  List of @{class:PhabricatorFeedStoryData} rows from the
-   *                    database.
+   * @param list<dict>  $rows List of @{class:PhabricatorFeedStoryData} rows
+   *                    from the database.
+   * @param PhabricatorUser $viewer
    * @return list<PhabricatorFeedStory>   List of @{class:PhabricatorFeedStory}
    *                                      objects.
    * @task load
    */
   public static function loadAllFromRows(array $rows, PhabricatorUser $viewer) {
     $stories = array();
 
     $data = id(new PhabricatorFeedStoryData())->loadAllFromArray($rows);
     foreach ($data as $story_data) {
       $class = $story_data->getStoryType();
 
       try {
         $ok =
           class_exists($class) &&
           is_subclass_of($class, __CLASS__);
       } catch (PhutilMissingSymbolException $ex) {
         $ok = false;
       }
 
       // If the story type isn't a valid class or isn't a subclass of
       // PhabricatorFeedStory, decline to load it.
       if (!$ok) {
         continue;
       }
 
       $key = $story_data->getChronologicalKey();
       $stories[$key] = newv($class, array($story_data));
     }
 
     $object_phids = array();
     $key_phids = array();
     foreach ($stories as $key => $story) {
       $phids = array();
       foreach ($story->getRequiredObjectPHIDs() as $phid) {
         $phids[$phid] = true;
       }
       if ($story->getPrimaryObjectPHID()) {
         $phids[$story->getPrimaryObjectPHID()] = true;
       }
       $key_phids[$key] = $phids;
       $object_phids += $phids;
     }
 
     $object_query = id(new PhabricatorObjectQuery())
       ->setViewer($viewer)
       ->withPHIDs(array_keys($object_phids));
 
     $objects = $object_query->execute();
 
     foreach ($key_phids as $key => $phids) {
       if (!$phids) {
         continue;
       }
       $story_objects = array_select_keys($objects, array_keys($phids));
       if (count($story_objects) != count($phids)) {
         // An object this story requires either does not exist or is not visible
         // to the user. Decline to render the story.
         unset($stories[$key]);
         unset($key_phids[$key]);
         continue;
       }
 
       $stories[$key]->setObjects($story_objects);
     }
 
     // If stories are about PhabricatorProjectInterface objects, load the
     // projects the objects are a part of so we can render project tags
     // on the stories.
 
     $project_phids = array();
     foreach ($objects as $object) {
       if ($object instanceof PhabricatorProjectInterface) {
         $project_phids[$object->getPHID()] = array();
       }
     }
 
     if ($project_phids) {
       $edge_query = id(new PhabricatorEdgeQuery())
         ->withSourcePHIDs(array_keys($project_phids))
         ->withEdgeTypes(
           array(
             PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
           ));
       $edge_query->execute();
       foreach ($project_phids as $phid => $ignored) {
         $project_phids[$phid] = $edge_query->getDestinationPHIDs(array($phid));
       }
     }
 
     $handle_phids = array();
     foreach ($stories as $key => $story) {
       foreach ($story->getRequiredHandlePHIDs() as $phid) {
         $key_phids[$key][$phid] = true;
       }
       if ($story->getAuthorPHID()) {
         $key_phids[$key][$story->getAuthorPHID()] = true;
       }
 
       $object_phid = $story->getPrimaryObjectPHID();
       $object_project_phids = idx($project_phids, $object_phid, array());
       $story->setProjectPHIDs($object_project_phids);
       foreach ($object_project_phids as $dst) {
         $key_phids[$key][$dst] = true;
       }
 
       $handle_phids += $key_phids[$key];
     }
 
     // NOTE: This setParentQuery() is a little sketchy. Ideally, this whole
     // method should be inside FeedQuery and it should be the parent query of
     // both subqueries. We're just trying to share the workspace cache.
 
     $handles = id(new PhabricatorHandleQuery())
       ->setViewer($viewer)
       ->setParentQuery($object_query)
       ->withPHIDs(array_keys($handle_phids))
       ->execute();
 
     foreach ($key_phids as $key => $phids) {
       if (!$phids) {
         continue;
       }
       $story_handles = array_select_keys($handles, array_keys($phids));
       $stories[$key]->setHandles($story_handles);
     }
 
     // Load and process story markup blocks.
 
     $engine = new PhabricatorMarkupEngine();
     $engine->setViewer($viewer);
     foreach ($stories as $story) {
       foreach ($story->getFieldStoryMarkupFields() as $field) {
         $engine->addObject($story, $field);
       }
     }
 
     $engine->process();
 
     foreach ($stories as $story) {
       foreach ($story->getFieldStoryMarkupFields() as $field) {
         $story->setMarkupFieldOutput(
           $field,
           $engine->getOutput($story, $field));
       }
     }
 
     return $stories;
   }
 
   public function setMarkupFieldOutput($field, $output) {
     $this->markupFieldOutput[$field] = $output;
     return $this;
   }
 
   public function getMarkupFieldOutput($field) {
     if (!array_key_exists($field, $this->markupFieldOutput)) {
       throw new Exception(
         pht(
           'Trying to retrieve markup field key "%s", but this feed story '.
           'did not request it be rendered.',
           $field));
     }
 
     return $this->markupFieldOutput[$field];
   }
 
   public function setHovercard($hover) {
     $this->hovercard = $hover;
     return $this;
   }
 
   public function setRenderingTarget($target) {
     $this->validateRenderingTarget($target);
     $this->renderingTarget = $target;
     return $this;
   }
 
   public function getRenderingTarget() {
     return $this->renderingTarget;
   }
 
   private function validateRenderingTarget($target) {
     switch ($target) {
       case PhabricatorApplicationTransaction::TARGET_HTML:
       case PhabricatorApplicationTransaction::TARGET_TEXT:
         break;
       default:
         throw new Exception(pht('Unknown rendering target: %s', $target));
         break;
     }
   }
 
   public function setObjects(array $objects) {
     $this->objects = $objects;
     return $this;
   }
 
   public function getObject($phid) {
     $object = idx($this->objects, $phid);
     if (!$object) {
       throw new Exception(
         pht(
           "Story is asking for an object it did not request ('%s')!",
           $phid));
     }
     return $object;
   }
 
   public function getPrimaryObject() {
     $phid = $this->getPrimaryObjectPHID();
     if (!$phid) {
       throw new Exception(pht('Story has no primary object!'));
     }
     return $this->getObject($phid);
   }
 
   public function getPrimaryObjectPHID() {
     return null;
   }
 
   final public function __construct(PhabricatorFeedStoryData $data) {
     $this->data = $data;
   }
 
   abstract public function renderView();
   public function renderAsTextForDoorkeeper(
     DoorkeeperFeedStoryPublisher $publisher) {
 
     // TODO: This (and text rendering) should be properly abstract and
     // universal. However, this is far less bad than it used to be, and we
     // need to clean up more old feed code to really make this reasonable.
 
     return pht(
       '(Unable to render story of class %s for Doorkeeper.)',
       get_class($this));
   }
 
   public function getRequiredHandlePHIDs() {
     return array();
   }
 
   public function getRequiredObjectPHIDs() {
     return array();
   }
 
   public function setHasViewed($has_viewed) {
     $this->hasViewed = $has_viewed;
     return $this;
   }
 
   public function getHasViewed() {
     return $this->hasViewed;
   }
 
   final public function setHandles(array $handles) {
     assert_instances_of($handles, 'PhabricatorObjectHandle');
     $this->handles = $handles;
     return $this;
   }
 
   final protected function getObjects() {
     return $this->objects;
   }
 
   final protected function getHandles() {
     return $this->handles;
   }
 
   final protected function getHandle($phid) {
     if (isset($this->handles[$phid])) {
       if ($this->handles[$phid] instanceof PhabricatorObjectHandle) {
         return $this->handles[$phid];
       }
     }
 
     $handle = new PhabricatorObjectHandle();
     $handle->setPHID($phid);
     $handle->setName(pht("Unloaded Object '%s'", $phid));
 
     return $handle;
   }
 
   final public function getStoryData() {
     return $this->data;
   }
 
   final public function getEpoch() {
     return $this->getStoryData()->getEpoch();
   }
 
   final public function getChronologicalKey() {
     return $this->getStoryData()->getChronologicalKey();
   }
 
   final public function getValue($key, $default = null) {
     return $this->getStoryData()->getValue($key, $default);
   }
 
   final public function getAuthorPHID() {
     return $this->getStoryData()->getAuthorPHID();
   }
 
   final protected function renderHandleList(array $phids) {
     $items = array();
     foreach ($phids as $phid) {
       $items[] = $this->linkTo($phid);
     }
     $list = null;
     switch ($this->getRenderingTarget()) {
       case PhabricatorApplicationTransaction::TARGET_TEXT:
         $list = implode(', ', $items);
         break;
       case PhabricatorApplicationTransaction::TARGET_HTML:
         $list = phutil_implode_html(', ', $items);
         break;
     }
     return $list;
   }
 
   final protected function linkTo($phid) {
     $handle = $this->getHandle($phid);
 
     switch ($this->getRenderingTarget()) {
       case PhabricatorApplicationTransaction::TARGET_TEXT:
         return $handle->getLinkName();
     }
 
     return $handle->renderLink();
   }
 
   final protected function renderString($str) {
     switch ($this->getRenderingTarget()) {
       case PhabricatorApplicationTransaction::TARGET_TEXT:
         return $str;
       case PhabricatorApplicationTransaction::TARGET_HTML:
         return phutil_tag('strong', array(), $str);
     }
   }
 
   final public function renderSummary($text, $len = 128) {
     if ($len) {
       $text = id(new PhutilUTF8StringTruncator())
         ->setMaximumGlyphs($len)
         ->truncateString($text);
     }
     switch ($this->getRenderingTarget()) {
       case PhabricatorApplicationTransaction::TARGET_HTML:
         $text = phutil_escape_html_newlines($text);
         break;
     }
     return $text;
   }
 
   public function getNotificationAggregations() {
     return array();
   }
 
   protected function newStoryView() {
     $view = id(new PHUIFeedStoryView())
       ->setChronologicalKey($this->getChronologicalKey())
       ->setEpoch($this->getEpoch())
       ->setViewed($this->getHasViewed());
 
     $project_phids = $this->getProjectPHIDs();
     if ($project_phids) {
       $view->setTags($this->renderHandleList($project_phids));
     }
 
     return $view;
   }
 
   public function setProjectPHIDs(array $phids) {
     $this->projectPHIDs = $phids;
     return $this;
   }
 
   public function getProjectPHIDs() {
     return $this->projectPHIDs;
   }
 
   public function getFieldStoryMarkupFields() {
     return array();
   }
 
   public function isVisibleInFeed() {
     return true;
   }
 
   public function isVisibleInNotifications() {
     return true;
   }
 
 
 /* -(  PhabricatorPolicyInterface Implementation  )-------------------------- */
 
   public function getPHID() {
     return null;
   }
 
   /**
    * @task policy
    */
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
     );
   }
 
 
   /**
    * @task policy
    */
   public function getPolicy($capability) {
     // NOTE: We enforce that a user can see all the objects a story is about
     // when loading it, so we don't need to perform a equivalent secondary
     // policy check later.
     return PhabricatorPolicies::getMostOpenPolicy();
   }
 
 
   /**
    * @task policy
    */
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return false;
   }
 
 
 /* -(  PhabricatorMarkupInterface Implementation )--------------------------- */
 
 
   public function getMarkupFieldKey($field) {
     return 'feed:'.$this->getChronologicalKey().':'.$field;
   }
 
   public function newMarkupEngine($field) {
     return PhabricatorMarkupEngine::getEngine('feed');
   }
 
   public function getMarkupText($field) {
     throw new PhutilMethodNotImplementedException();
   }
 
   public function didMarkupText(
     $field,
     $output,
     PhutilMarkupEngine $engine) {
     return $output;
   }
 
   public function shouldUseMarkupCache($field) {
     return true;
   }
 
 }
diff --git a/src/applications/files/PhabricatorImageTransformer.php b/src/applications/files/PhabricatorImageTransformer.php
index 1075969a42..0ca475e90c 100644
--- a/src/applications/files/PhabricatorImageTransformer.php
+++ b/src/applications/files/PhabricatorImageTransformer.php
@@ -1,138 +1,138 @@
 <?php
 
 /**
  * @task enormous Detecting Enormous Images
  * @task save     Saving Image Data
  */
 final class PhabricatorImageTransformer extends Phobject {
 
 
 /* -(  Saving Image Data  )-------------------------------------------------- */
 
 
   /**
    * Save an image resource to a string representation suitable for storage or
    * transmission as an image file.
    *
    * Optionally, you can specify a preferred MIME type like `"image/png"`.
    * Generally, you should specify the MIME type of the original file if you're
    * applying file transformations. The MIME type may not be honored if
    * Phabricator can not encode images in the given format (based on available
    * extensions), but can save images in another format.
    *
-   * @param   resource  GD image resource.
-   * @param   string?   Optionally, preferred mime type.
+   * @param   resource  $data GD image resource.
+   * @param   string?   $preferred_mime Optionally, preferred mime type.
    * @return  string    Bytes of an image file.
    * @task save
    */
   public static function saveImageDataInAnyFormat($data, $preferred_mime = '') {
     $preferred = null;
     switch ($preferred_mime) {
       case 'image/gif':
         $preferred = self::saveImageDataAsGIF($data);
         break;
       case 'image/png':
         $preferred = self::saveImageDataAsPNG($data);
         break;
     }
 
     if ($preferred !== null) {
       return $preferred;
     }
 
     $data = self::saveImageDataAsJPG($data);
     if ($data !== null) {
       return $data;
     }
 
     $data = self::saveImageDataAsPNG($data);
     if ($data !== null) {
       return $data;
     }
 
     $data = self::saveImageDataAsGIF($data);
     if ($data !== null) {
       return $data;
     }
 
     throw new Exception(pht('Failed to save image data into any format.'));
   }
 
 
   /**
    * Save an image in PNG format, returning the file data as a string.
    *
-   * @param resource      GD image resource.
+   * @param resource      $image GD image resource.
    * @return string|null  PNG file as a string, or null on failure.
    * @task save
    */
   private static function saveImageDataAsPNG($image) {
     if (!function_exists('imagepng')) {
       return null;
     }
 
     // NOTE: Empirically, the highest compression level (9) seems to take
     // up to twice as long as the default compression level (6) but produce
     // only slightly smaller files (10% on avatars, 3% on screenshots).
 
     ob_start();
     $result = imagepng($image, null, 6);
     $output = ob_get_clean();
 
     if (!$result) {
       return null;
     }
 
     return $output;
   }
 
 
   /**
    * Save an image in GIF format, returning the file data as a string.
    *
-   * @param resource      GD image resource.
+   * @param resource      $image GD image resource.
    * @return string|null  GIF file as a string, or null on failure.
    * @task save
    */
   private static function saveImageDataAsGIF($image) {
     if (!function_exists('imagegif')) {
       return null;
     }
 
     ob_start();
     $result = imagegif($image);
     $output = ob_get_clean();
 
     if (!$result) {
       return null;
     }
 
     return $output;
   }
 
 
   /**
    * Save an image in JPG format, returning the file data as a string.
    *
-   * @param resource      GD image resource.
+   * @param resource      $image GD image resource.
    * @return string|null  JPG file as a string, or null on failure.
    * @task save
    */
   private static function saveImageDataAsJPG($image) {
     if (!function_exists('imagejpeg')) {
       return null;
     }
 
     ob_start();
     $result = imagejpeg($image);
     $output = ob_get_clean();
 
     if (!$result) {
       return null;
     }
 
     return $output;
   }
 
 
 }
diff --git a/src/applications/files/engine/PhabricatorChunkedFileStorageEngine.php b/src/applications/files/engine/PhabricatorChunkedFileStorageEngine.php
index 30de63b904..596bdc1c60 100644
--- a/src/applications/files/engine/PhabricatorChunkedFileStorageEngine.php
+++ b/src/applications/files/engine/PhabricatorChunkedFileStorageEngine.php
@@ -1,197 +1,197 @@
 <?php
 
 final class PhabricatorChunkedFileStorageEngine
   extends PhabricatorFileStorageEngine {
 
   public function getEngineIdentifier() {
     return 'chunks';
   }
 
   public function getEnginePriority() {
     return 60000;
   }
 
   /**
    * We can write chunks if we have at least one valid storage engine
    * underneath us.
    */
   public function canWriteFiles() {
     return (bool)$this->getWritableEngine();
   }
 
   public function hasFilesizeLimit() {
     return false;
   }
 
   public function isChunkEngine() {
     return true;
   }
 
   public function writeFile($data, array $params) {
     // The chunk engine does not support direct writes.
     throw new PhutilMethodNotImplementedException();
   }
 
   public function readFile($handle) {
     // This is inefficient, but makes the API work as expected.
     $chunks = $this->loadAllChunks($handle, true);
 
     $buffer = '';
     foreach ($chunks as $chunk) {
       $data_file = $chunk->getDataFile();
       if (!$data_file) {
         throw new Exception(pht('This file data is incomplete!'));
       }
 
       $buffer .= $chunk->getDataFile()->loadFileData();
     }
 
     return $buffer;
   }
 
   public function deleteFile($handle) {
     $engine = new PhabricatorDestructionEngine();
     $chunks = $this->loadAllChunks($handle, true);
     foreach ($chunks as $chunk) {
       $engine->destroyObject($chunk);
     }
   }
 
   private function loadAllChunks($handle, $need_files) {
     $chunks = id(new PhabricatorFileChunkQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withChunkHandles(array($handle))
       ->needDataFiles($need_files)
       ->execute();
 
     $chunks = msort($chunks, 'getByteStart');
 
     return $chunks;
   }
 
   /**
    * Compute a chunked file hash for the viewer.
    *
    * We can not currently compute a real hash for chunked file uploads (because
    * no process sees all of the file data).
    *
    * We also can not trust the hash that the user claims to have computed. If
    * we trust the user, they can upload some `evil.exe` and claim it has the
    * same file hash as `good.exe`. When another user later uploads the real
    * `good.exe`, we'll just create a reference to the existing `evil.exe`. Users
    * who download `good.exe` will then receive `evil.exe`.
    *
    * Instead, we rehash the user's claimed hash with account secrets. This
    * allows users to resume file uploads, but not collide with other users.
    *
    * Ideally, we'd like to be able to verify hashes, but this is complicated
    * and time consuming and gives us a fairly small benefit.
    *
-   * @param PhabricatorUser Viewing user.
-   * @param string Claimed file hash.
+   * @param PhabricatorUser $viewer Viewing user.
+   * @param string $hash Claimed file hash.
    * @return string Rehashed file hash.
    */
   public static function getChunkedHash(PhabricatorUser $viewer, $hash) {
     if (!$viewer->getPHID()) {
       throw new Exception(
         pht('Unable to compute chunked hash without real viewer!'));
     }
 
     $input = $viewer->getAccountSecret().':'.$hash.':'.$viewer->getPHID();
     return self::getChunkedHashForInput($input);
   }
 
   public static function getChunkedHashForInput($input) {
     $rehash = PhabricatorHash::weakDigest($input);
 
     // Add a suffix to identify this as a chunk hash.
     $rehash = substr($rehash, 0, -2).'-C';
 
     return $rehash;
   }
 
   public function allocateChunks($length, array $properties) {
     $file = PhabricatorFile::newChunkedFile($this, $length, $properties);
 
     $chunk_size = $this->getChunkSize();
 
     $handle = $file->getStorageHandle();
 
     $chunks = array();
     for ($ii = 0; $ii < $length; $ii += $chunk_size) {
       $chunks[] = PhabricatorFileChunk::initializeNewChunk(
         $handle,
         $ii,
         min($ii + $chunk_size, $length));
     }
 
     $file->openTransaction();
       foreach ($chunks as $chunk) {
         $chunk->save();
       }
       $file->saveAndIndex();
     $file->saveTransaction();
 
     return $file;
   }
 
   /**
    * Find a storage engine which is suitable for storing chunks.
    *
    * This engine must be a writable engine, have a filesize limit larger than
    * the chunk limit, and must not be a chunk engine itself.
    */
   private function getWritableEngine() {
     // NOTE: We can't just load writable engines or we'll loop forever.
     $engines = parent::loadAllEngines();
 
     foreach ($engines as $engine) {
       if ($engine->isChunkEngine()) {
         continue;
       }
 
       if ($engine->isTestEngine()) {
         continue;
       }
 
       if (!$engine->canWriteFiles()) {
         continue;
       }
 
       if ($engine->hasFilesizeLimit()) {
         if ($engine->getFilesizeLimit() < $this->getChunkSize()) {
           continue;
         }
       }
 
       return true;
     }
 
     return false;
   }
 
   public function getChunkSize() {
     return (4 * 1024 * 1024);
   }
 
   public function getRawFileDataIterator(
     PhabricatorFile $file,
     $begin,
     $end,
     PhabricatorFileStorageFormat $format) {
 
     // NOTE: It is currently impossible for files stored with the chunk
     // engine to have their own formatting (instead, the individual chunks
     // are formatted), so we ignore the format object.
 
     $chunks = id(new PhabricatorFileChunkQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withChunkHandles(array($file->getStorageHandle()))
       ->withByteRange($begin, $end)
       ->needDataFiles(true)
       ->execute();
 
     return new PhabricatorFileChunkIterator($chunks, $begin, $end);
   }
 
 }
diff --git a/src/applications/files/engine/PhabricatorFileStorageEngine.php b/src/applications/files/engine/PhabricatorFileStorageEngine.php
index a1e5131614..32c8ed13f8 100644
--- a/src/applications/files/engine/PhabricatorFileStorageEngine.php
+++ b/src/applications/files/engine/PhabricatorFileStorageEngine.php
@@ -1,382 +1,382 @@
 <?php
 
 /**
  * Defines a storage engine which can write file data somewhere (like a
  * database, local disk, Amazon S3, the A:\ drive, or a custom filer) and
  * retrieve it later.
  *
  * You can extend this class to provide new file storage backends.
  *
  * For more information, see @{article:Configuring File Storage}.
  *
  * @task construct Constructing an Engine
  * @task meta Engine Metadata
  * @task file Managing File Data
  * @task load Loading Storage Engines
  */
 abstract class PhabricatorFileStorageEngine extends Phobject {
 
   const HMAC_INTEGRITY = 'file.integrity';
 
   /**
    * Construct a new storage engine.
    *
    * @task construct
    */
   final public function __construct() {
     // <empty>
   }
 
 
 /* -(  Engine Metadata  )---------------------------------------------------- */
 
 
   /**
    * Return a unique, nonempty string which identifies this storage engine.
    * This is used to look up the storage engine when files needs to be read or
    * deleted. For instance, if you store files by giving them to a duck for
    * safe keeping in his nest down by the pond, you might return 'duck' from
    * this method.
    *
    * @return string Unique string for this engine, max length 32.
    * @task meta
    */
   abstract public function getEngineIdentifier();
 
 
   /**
    * Prioritize this engine relative to other engines.
    *
    * Engines with a smaller priority number get an opportunity to write files
    * first. Generally, lower-latency filestores should have lower priority
    * numbers, and higher-latency filestores should have higher priority
    * numbers. Setting priority to approximately the number of milliseconds of
    * read latency will generally produce reasonable results.
    *
    * In conjunction with filesize limits, the goal is to store small files like
    * profile images, thumbnails, and text snippets in lower-latency engines,
    * and store large files in higher-capacity engines.
    *
    * @return float Engine priority.
    * @task meta
    */
   abstract public function getEnginePriority();
 
 
   /**
    * Return `true` if the engine is currently writable.
    *
    * Engines that are disabled or missing configuration should return `false`
    * to prevent new writes. If writes were made with this engine in the past,
    * the application may still try to perform reads.
    *
    * @return bool True if this engine can support new writes.
    * @task meta
    */
   abstract public function canWriteFiles();
 
 
   /**
    * Return `true` if the engine has a filesize limit on storable files.
    *
    * The @{method:getFilesizeLimit} method can retrieve the actual limit. This
    * method just removes the ambiguity around the meaning of a `0` limit.
    *
    * @return bool `true` if the engine has a filesize limit.
    * @task meta
    */
   public function hasFilesizeLimit() {
     return true;
   }
 
 
   /**
    * Return maximum storable file size, in bytes.
    *
    * Not all engines have a limit; use @{method:getFilesizeLimit} to check if
    * an engine has a limit. Engines without a limit can store files of any
    * size.
    *
    * By default, engines define a limit which supports chunked storage of
    * large files. In most cases, you should not change this limit, even if an
    * engine has vast storage capacity: chunked storage makes large files more
    * manageable and enables features like resumable uploads.
    *
    * @return int Maximum storable file size, in bytes.
    * @task meta
    */
   public function getFilesizeLimit() {
     // NOTE: This 8MB limit is selected to be larger than the 4MB chunk size,
     // but not much larger. Files between 0MB and 8MB will be stored normally;
     // files larger than 8MB will be chunked.
     return (1024 * 1024 * 8);
   }
 
 
   /**
    * Identifies storage engines that support unit tests.
    *
    * These engines are not used for production writes.
    *
    * @return bool True if this is a test engine.
    * @task meta
    */
   public function isTestEngine() {
     return false;
   }
 
 
   /**
    * Identifies chunking storage engines.
    *
    * If this is a storage engine which splits files into chunks and stores the
    * chunks in other engines, it can return `true` to signal that other
    * chunking engines should not try to store data here.
    *
    * @return bool True if this is a chunk engine.
    * @task meta
    */
   public function isChunkEngine() {
     return false;
   }
 
 
 /* -(  Managing File Data  )------------------------------------------------- */
 
 
   /**
    * Write file data to the backing storage and return a handle which can later
    * be used to read or delete it. For example, if the backing storage is local
    * disk, the handle could be the path to the file.
    *
    * The caller will provide a $params array, which may be empty or may have
    * some metadata keys (like "name" and "author") in it. You should be prepared
    * to handle writes which specify no metadata, but might want to optionally
    * use some keys in this array for debugging or logging purposes. This is
    * the same dictionary passed to @{method:PhabricatorFile::newFromFileData},
    * so you could conceivably do custom things with it.
    *
    * If you are unable to write for whatever reason (e.g., the disk is full),
    * throw an exception. If there are other satisfactory but less-preferred
    * storage engines available, they will be tried.
    *
-   * @param  string The file data to write.
-   * @param  array  File metadata (name, author), if available.
+   * @param  string $data The file data to write.
+   * @param  array  $params File metadata (name, author), if available.
    * @return string Unique string which identifies the stored file, max length
    *                255.
    * @task file
    */
   abstract public function writeFile($data, array $params);
 
 
   /**
    * Read the contents of a file previously written by @{method:writeFile}.
    *
-   * @param   string  The handle returned from @{method:writeFile} when the
-   *                  file was written.
+   * @param   string  $handle The handle returned from @{method:writeFile}
+   *                  when the file was written.
    * @return  string  File contents.
    * @task file
    */
   abstract public function readFile($handle);
 
 
   /**
    * Delete the data for a file previously written by @{method:writeFile}.
    *
-   * @param   string  The handle returned from @{method:writeFile} when the
-   *                  file was written.
+   * @param   string  $handle The handle returned from @{method:writeFile}
+   *                  when the file was written.
    * @return  void
    * @task file
    */
   abstract public function deleteFile($handle);
 
 
 
 /* -(  Loading Storage Engines  )-------------------------------------------- */
 
 
   /**
    * Select viable default storage engines according to configuration. We'll
    * select the MySQL and Local Disk storage engines if they are configured
    * to allow a given file.
    *
-   * @param int File size in bytes.
+   * @param int $length File size in bytes.
    * @task load
    */
   public static function loadStorageEngines($length) {
     $engines = self::loadWritableEngines();
 
     $writable = array();
     foreach ($engines as $key => $engine) {
       if ($engine->hasFilesizeLimit()) {
         $limit = $engine->getFilesizeLimit();
         if ($limit < $length) {
           continue;
         }
       }
 
       $writable[$key] = $engine;
     }
 
     return $writable;
   }
 
 
   /**
    * @task load
    */
   public static function loadAllEngines() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getEngineIdentifier')
       ->setSortMethod('getEnginePriority')
       ->execute();
   }
 
 
   /**
    * @task load
    */
   private static function loadProductionEngines() {
     $engines = self::loadAllEngines();
 
     $active = array();
     foreach ($engines as $key => $engine) {
       if ($engine->isTestEngine()) {
         continue;
       }
 
       $active[$key] = $engine;
     }
 
     return $active;
   }
 
 
   /**
    * @task load
    */
   public static function loadWritableEngines() {
     $engines = self::loadProductionEngines();
 
     $writable = array();
     foreach ($engines as $key => $engine) {
       if (!$engine->canWriteFiles()) {
         continue;
       }
 
       if ($engine->isChunkEngine()) {
         // Don't select chunk engines as writable.
         continue;
       }
       $writable[$key] = $engine;
     }
 
     return $writable;
   }
 
   /**
    * @task load
    */
   public static function loadWritableChunkEngines() {
     $engines = self::loadProductionEngines();
 
     $chunk = array();
     foreach ($engines as $key => $engine) {
       if (!$engine->canWriteFiles()) {
         continue;
       }
       if (!$engine->isChunkEngine()) {
         continue;
       }
       $chunk[$key] = $engine;
     }
 
     return $chunk;
   }
 
 
 
   /**
    * Return the largest file size which can not be uploaded in chunks.
    *
    * Files smaller than this will always upload in one request, so clients
    * can safely skip the allocation step.
    *
    * @return int|null Byte size, or `null` if there is no chunk support.
    */
   public static function getChunkThreshold() {
     $engines = self::loadWritableChunkEngines();
 
     $min = null;
     foreach ($engines as $engine) {
       if (!$min) {
         $min = $engine;
         continue;
       }
 
       if ($min->getChunkSize() > $engine->getChunkSize()) {
         $min = $engine->getChunkSize();
       }
     }
 
     if (!$min) {
       return null;
     }
 
     return $engine->getChunkSize();
   }
 
   public function getRawFileDataIterator(
     PhabricatorFile $file,
     $begin,
     $end,
     PhabricatorFileStorageFormat $format) {
 
     $formatted_data = $this->readFile($file->getStorageHandle());
 
     $known_integrity = $file->getIntegrityHash();
     if ($known_integrity !== null) {
       $new_integrity = $this->newIntegrityHash($formatted_data, $format);
       if (!phutil_hashes_are_identical($known_integrity, $new_integrity)) {
         throw new PhabricatorFileIntegrityException(
           pht(
             'File data integrity check failed. Dark forces have corrupted '.
             'or tampered with this file. The file data can not be read.'));
       }
     }
 
     $formatted_data = array($formatted_data);
 
     $data = '';
     $format_iterator = $format->newReadIterator($formatted_data);
     foreach ($format_iterator as $raw_chunk) {
       $data .= $raw_chunk;
     }
 
     if ($begin !== null && $end !== null) {
       $data = substr($data, $begin, ($end - $begin));
     } else if ($begin !== null) {
       $data = substr($data, $begin);
     } else if ($end !== null) {
       $data = substr($data, 0, $end);
     }
 
     return array($data);
   }
 
   public function newIntegrityHash(
     $data,
     PhabricatorFileStorageFormat $format) {
 
     $hmac_name = self::HMAC_INTEGRITY;
 
     $data_hash = PhabricatorHash::digestWithNamedKey($data, $hmac_name);
     $format_hash = $format->newFormatIntegrityHash();
 
     $full_hash = "{$data_hash}/{$format_hash}";
 
     return PhabricatorHash::digestWithNamedKey($full_hash, $hmac_name);
   }
 
 }
diff --git a/src/applications/files/engine/PhabricatorLocalDiskFileStorageEngine.php b/src/applications/files/engine/PhabricatorLocalDiskFileStorageEngine.php
index afed9f20b3..04de4a14a1 100644
--- a/src/applications/files/engine/PhabricatorLocalDiskFileStorageEngine.php
+++ b/src/applications/files/engine/PhabricatorLocalDiskFileStorageEngine.php
@@ -1,137 +1,137 @@
 <?php
 
 /**
  * Local disk storage engine. Keeps files on local disk. This engine is easy
  * to set up, but it doesn't work if you have multiple web frontends!
  *
  * @task internal Internals
  */
 final class PhabricatorLocalDiskFileStorageEngine
   extends PhabricatorFileStorageEngine {
 
 
 /* -(  Engine Metadata  )---------------------------------------------------- */
 
 
   /**
    * This engine identifies as "local-disk".
    */
   public function getEngineIdentifier() {
     return 'local-disk';
   }
 
   public function getEnginePriority() {
     return 5;
   }
 
   public function canWriteFiles() {
     $path = PhabricatorEnv::getEnvConfig('storage.local-disk.path');
     $path = phutil_string_cast($path);
     return (bool)strlen($path);
   }
 
 
 /* -(  Managing File Data  )------------------------------------------------- */
 
 
   /**
    * Write the file data to local disk. Returns the relative path as the
    * file data handle.
    * @task impl
    */
   public function writeFile($data, array $params) {
     $root = $this->getLocalDiskFileStorageRoot();
 
     // Generate a random, unique file path like "ab/29/1f918a9ac39201ff". We
     // put a couple of subdirectories up front to avoid a situation where we
     // have one directory with a zillion files in it, since this is generally
     // bad news.
     do {
       $name = md5(mt_rand());
       $name = preg_replace('/^(..)(..)(.*)$/', '\\1/\\2/\\3', $name);
       if (!Filesystem::pathExists($root.'/'.$name)) {
         break;
       }
     } while (true);
 
     $parent = $root.'/'.dirname($name);
     if (!Filesystem::pathExists($parent)) {
       execx('mkdir -p %s', $parent);
     }
 
     AphrontWriteGuard::willWrite();
     Filesystem::writeFile($root.'/'.$name, $data);
 
     return $name;
   }
 
 
   /**
    * Read the file data off local disk.
    * @task impl
    */
   public function readFile($handle) {
     $path = $this->getLocalDiskFileStorageFullPath($handle);
     return Filesystem::readFile($path);
   }
 
 
   /**
    * Deletes the file from local disk, if it exists.
    * @task impl
    */
   public function deleteFile($handle) {
     $path = $this->getLocalDiskFileStorageFullPath($handle);
     if (Filesystem::pathExists($path)) {
       AphrontWriteGuard::willWrite();
       Filesystem::remove($path);
     }
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * Get the configured local disk path for file storage.
    *
    * @return string Absolute path to somewhere that files can be stored.
    * @task internal
    */
   private function getLocalDiskFileStorageRoot() {
     $root = PhabricatorEnv::getEnvConfig('storage.local-disk.path');
 
     if (!$root || $root == '/' || $root[0] != '/') {
       throw new PhabricatorFileStorageConfigurationException(
         pht(
           "Malformed local disk storage root. You must provide an absolute ".
           "path, and can not use '%s' as the root.",
           '/'));
     }
 
     return rtrim($root, '/');
   }
 
 
   /**
    * Convert a handle into an absolute local disk path.
    *
-   * @param string File data handle.
+   * @param string $handle File data handle.
    * @return string Absolute path to the corresponding file.
    * @task internal
    */
   private function getLocalDiskFileStorageFullPath($handle) {
     // Make sure there's no funny business going on here. Users normally have
     // no ability to affect the content of handles, but double-check that
     // we're only accessing local storage just in case.
     if (!preg_match('@^[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]{28}\z@', $handle)) {
       throw new Exception(
         pht(
           "Local disk filesystem handle '%s' is malformed!",
           $handle));
     }
     $root = $this->getLocalDiskFileStorageRoot();
     return $root.'/'.$handle;
   }
 
 }
diff --git a/src/applications/files/engine/PhabricatorMySQLFileStorageEngine.php b/src/applications/files/engine/PhabricatorMySQLFileStorageEngine.php
index eb49ef78c3..5d8ba94bdb 100644
--- a/src/applications/files/engine/PhabricatorMySQLFileStorageEngine.php
+++ b/src/applications/files/engine/PhabricatorMySQLFileStorageEngine.php
@@ -1,95 +1,95 @@
 <?php
 
 /**
  * MySQL blob storage engine. This engine is the easiest to set up but doesn't
  * scale very well.
  *
  * It uses the @{class:PhabricatorFileStorageBlob} to actually access the
  * underlying database table.
  *
  * @task internal Internals
  */
 final class PhabricatorMySQLFileStorageEngine
   extends PhabricatorFileStorageEngine {
 
 
 /* -(  Engine Metadata  )---------------------------------------------------- */
 
 
   /**
    * For historical reasons, this engine identifies as "blob".
    */
   public function getEngineIdentifier() {
     return 'blob';
   }
 
   public function getEnginePriority() {
     return 1;
   }
 
   public function canWriteFiles() {
     return ($this->getFilesizeLimit() > 0);
   }
 
 
   public function hasFilesizeLimit() {
     return true;
   }
 
 
   public function getFilesizeLimit() {
     return PhabricatorEnv::getEnvConfig('storage.mysql-engine.max-size');
   }
 
 
 /* -(  Managing File Data  )------------------------------------------------- */
 
 
   /**
    * Write file data into the big blob store table in MySQL. Returns the row
    * ID as the file data handle.
    */
   public function writeFile($data, array $params) {
     $blob = new PhabricatorFileStorageBlob();
     $blob->setData($data);
     $blob->save();
 
     return $blob->getID();
   }
 
 
   /**
    * Load a stored blob from MySQL.
    */
   public function readFile($handle) {
     return $this->loadFromMySQLFileStorage($handle)->getData();
   }
 
 
   /**
    * Delete a blob from MySQL.
    */
   public function deleteFile($handle) {
     $this->loadFromMySQLFileStorage($handle)->delete();
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * Load the Lisk object that stores the file data for a handle.
    *
-   * @param string  File data handle.
+   * @param string $handle File data handle.
    * @return PhabricatorFileStorageBlob Data DAO.
    * @task internal
    */
   private function loadFromMySQLFileStorage($handle) {
     $blob = id(new PhabricatorFileStorageBlob())->load($handle);
     if (!$blob) {
       throw new Exception(pht("Unable to load MySQL blob file '%s'!", $handle));
     }
     return $blob;
   }
 
 }
diff --git a/src/applications/files/query/PhabricatorFileQuery.php b/src/applications/files/query/PhabricatorFileQuery.php
index e712d533b0..bb81d54b7d 100644
--- a/src/applications/files/query/PhabricatorFileQuery.php
+++ b/src/applications/files/query/PhabricatorFileQuery.php
@@ -1,546 +1,547 @@
 <?php
 
 final class PhabricatorFileQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $ids;
   private $phids;
   private $authorPHIDs;
   private $explicitUploads;
   private $transforms;
   private $dateCreatedAfter;
   private $dateCreatedBefore;
   private $contentHashes;
   private $minLength;
   private $maxLength;
   private $names;
   private $isPartial;
   private $isDeleted;
   private $needTransforms;
   private $builtinKeys;
   private $isBuiltin;
   private $storageEngines;
   private $attachedObjectPHIDs;
 
   public function withIDs(array $ids) {
     $this->ids = $ids;
     return $this;
   }
 
   public function withPHIDs(array $phids) {
     $this->phids = $phids;
     return $this;
   }
 
   public function withAuthorPHIDs(array $phids) {
     $this->authorPHIDs = $phids;
     return $this;
   }
 
   public function withDateCreatedBefore($date_created_before) {
     $this->dateCreatedBefore = $date_created_before;
     return $this;
   }
 
   public function withDateCreatedAfter($date_created_after) {
     $this->dateCreatedAfter = $date_created_after;
     return $this;
   }
 
   public function withContentHashes(array $content_hashes) {
     $this->contentHashes = $content_hashes;
     return $this;
   }
 
   public function withBuiltinKeys(array $keys) {
     $this->builtinKeys = $keys;
     return $this;
   }
 
   public function withIsBuiltin($is_builtin) {
     $this->isBuiltin = $is_builtin;
     return $this;
   }
 
   public function withAttachedObjectPHIDs(array $phids) {
     $this->attachedObjectPHIDs = $phids;
     return $this;
   }
 
   /**
    * Select files which are transformations of some other file. For example,
    * you can use this query to find previously generated thumbnails of an image
    * file.
    *
    * As a parameter, provide a list of transformation specifications. Each
    * specification is a dictionary with the keys `originalPHID` and `transform`.
    * The `originalPHID` is the PHID of the original file (the file which was
    * transformed) and the `transform` is the name of the transform to query
    * for. If you pass `true` as the `transform`, all transformations of the
    * file will be selected.
    *
    * For example:
    *
    *   array(
    *     array(
    *       'originalPHID' => 'PHID-FILE-aaaa',
    *       'transform'    => 'sepia',
    *     ),
    *     array(
    *       'originalPHID' => 'PHID-FILE-bbbb',
    *       'transform'    => true,
    *     ),
    *   )
    *
    * This selects the `"sepia"` transformation of the file with PHID
    * `PHID-FILE-aaaa` and all transformations of the file with PHID
    * `PHID-FILE-bbbb`.
    *
-   * @param list<dict>  List of transform specifications, described above.
+   * @param list<dict> $specs List of transform specifications, described
+   *                   above.
    * @return this
    */
   public function withTransforms(array $specs) {
     foreach ($specs as $spec) {
       if (!is_array($spec) ||
           empty($spec['originalPHID']) ||
           empty($spec['transform'])) {
         throw new Exception(
           pht(
             "Transform specification must be a dictionary with keys ".
             "'%s' and '%s'!",
             'originalPHID',
             'transform'));
       }
     }
 
     $this->transforms = $specs;
     return $this;
   }
 
   public function withLengthBetween($min, $max) {
     $this->minLength = $min;
     $this->maxLength = $max;
     return $this;
   }
 
   public function withNames(array $names) {
     $this->names = $names;
     return $this;
   }
 
   public function withIsPartial($partial) {
     $this->isPartial = $partial;
     return $this;
   }
 
   public function withIsDeleted($deleted) {
     $this->isDeleted = $deleted;
     return $this;
   }
 
   public function withNameNgrams($ngrams) {
     return $this->withNgramsConstraint(
       id(new PhabricatorFileNameNgrams()),
       $ngrams);
   }
 
   public function withStorageEngines(array $engines) {
     $this->storageEngines = $engines;
     return $this;
   }
 
   public function showOnlyExplicitUploads($explicit_uploads) {
     $this->explicitUploads = $explicit_uploads;
     return $this;
   }
 
   public function needTransforms(array $transforms) {
     $this->needTransforms = $transforms;
     return $this;
   }
 
   public function newResultObject() {
     return new PhabricatorFile();
   }
 
   protected function loadPage() {
     $files = $this->loadStandardPage($this->newResultObject());
 
     if (!$files) {
       return $files;
     }
 
     // Figure out which files we need to load attached objects for. In most
     // cases, we need to load attached objects to perform policy checks for
     // files.
 
     // However, in some special cases where we know files will always be
     // visible, we skip this. See T8478 and T13106.
     $need_objects = array();
     $need_xforms = array();
     foreach ($files as $file) {
       $always_visible = false;
 
       if ($file->getIsProfileImage()) {
         $always_visible = true;
       }
 
       if ($file->isBuiltin()) {
         $always_visible = true;
       }
 
       if ($always_visible) {
         // We just treat these files as though they aren't attached to
         // anything. This saves a query in common cases when we're loading
         // profile images or builtins. We could be slightly more nuanced
         // about this and distinguish between "not attached to anything" and
         // "might be attached but policy checks don't need to care".
         $file->attachObjectPHIDs(array());
         continue;
       }
 
       $need_objects[] = $file;
       $need_xforms[] = $file;
     }
 
     $viewer = $this->getViewer();
     $is_omnipotent = $viewer->isOmnipotent();
 
     // If we have any files left which do need objects, load the edges now.
     $object_phids = array();
     if ($need_objects) {
       $attachments_map = $this->newAttachmentsMap($need_objects);
 
       foreach ($need_objects as $file) {
         $file_phid = $file->getPHID();
         $phids = $attachments_map[$file_phid];
 
         $file->attachObjectPHIDs($phids);
 
         if ($is_omnipotent) {
           // If the viewer is omnipotent, we don't need to load the associated
           // objects either since the viewer can certainly see the object.
           // Skipping this can improve performance and prevent cycles. This
           // could possibly become part of the profile/builtin code above which
           // short circuits attacment policy checks in cases where we know them
           // to be unnecessary.
           continue;
         }
 
         foreach ($phids as $phid) {
           $object_phids[$phid] = true;
         }
       }
     }
 
     // If this file is a transform of another file, load that file too. If you
     // can see the original file, you can see the thumbnail.
 
     // TODO: It might be nice to put this directly on PhabricatorFile and
     // remove the PhabricatorTransformedFile table, which would be a little
     // simpler.
 
     if ($need_xforms) {
       $xforms = id(new PhabricatorTransformedFile())->loadAllWhere(
         'transformedPHID IN (%Ls)',
         mpull($need_xforms, 'getPHID'));
       $xform_phids = mpull($xforms, 'getOriginalPHID', 'getTransformedPHID');
       foreach ($xform_phids as $derived_phid => $original_phid) {
         $object_phids[$original_phid] = true;
       }
     } else {
       $xform_phids = array();
     }
 
     $object_phids = array_keys($object_phids);
 
     // Now, load the objects.
 
     $objects = array();
     if ($object_phids) {
       // NOTE: We're explicitly turning policy exceptions off, since the rule
       // here is "you can see the file if you can see ANY associated object".
       // Without this explicit flag, we'll incorrectly throw unless you can
       // see ALL associated objects.
 
       $objects = id(new PhabricatorObjectQuery())
         ->setParentQuery($this)
         ->setViewer($this->getViewer())
         ->withPHIDs($object_phids)
         ->setRaisePolicyExceptions(false)
         ->execute();
       $objects = mpull($objects, null, 'getPHID');
     }
 
     foreach ($files as $file) {
       $file_objects = array_select_keys($objects, $file->getObjectPHIDs());
       $file->attachObjects($file_objects);
     }
 
     foreach ($files as $key => $file) {
       $original_phid = idx($xform_phids, $file->getPHID());
       if ($original_phid == PhabricatorPHIDConstants::PHID_VOID) {
         // This is a special case for builtin files, which are handled
         // oddly.
         $original = null;
       } else if ($original_phid) {
         $original = idx($objects, $original_phid);
         if (!$original) {
           // If the viewer can't see the original file, also prevent them from
           // seeing the transformed file.
           $this->didRejectResult($file);
           unset($files[$key]);
           continue;
         }
       } else {
         $original = null;
       }
       $file->attachOriginalFile($original);
     }
 
     return $files;
   }
 
   private function newAttachmentsMap(array $files) {
     $file_phids = mpull($files, 'getPHID');
 
     $attachments_table = new PhabricatorFileAttachment();
     $attachments_conn = $attachments_table->establishConnection('r');
 
     $attachments = queryfx_all(
       $attachments_conn,
       'SELECT filePHID, objectPHID FROM %R WHERE filePHID IN (%Ls)
         AND attachmentMode IN (%Ls)',
       $attachments_table,
       $file_phids,
       array(
         PhabricatorFileAttachment::MODE_ATTACH,
       ));
 
     $attachments_map = array_fill_keys($file_phids, array());
     foreach ($attachments as $row) {
       $file_phid = $row['filePHID'];
       $object_phid = $row['objectPHID'];
       $attachments_map[$file_phid][] = $object_phid;
     }
 
     return $attachments_map;
   }
 
   protected function didFilterPage(array $files) {
     $xform_keys = $this->needTransforms;
     if ($xform_keys !== null) {
       $xforms = id(new PhabricatorTransformedFile())->loadAllWhere(
         'originalPHID IN (%Ls) AND transform IN (%Ls)',
         mpull($files, 'getPHID'),
         $xform_keys);
 
       if ($xforms) {
         $xfiles = id(new PhabricatorFile())->loadAllWhere(
           'phid IN (%Ls)',
           mpull($xforms, 'getTransformedPHID'));
         $xfiles = mpull($xfiles, null, 'getPHID');
       }
 
       $xform_map = array();
       foreach ($xforms as $xform) {
         $xfile = idx($xfiles, $xform->getTransformedPHID());
         if (!$xfile) {
           continue;
         }
         $original_phid = $xform->getOriginalPHID();
         $xform_key = $xform->getTransform();
         $xform_map[$original_phid][$xform_key] = $xfile;
       }
 
       $default_xforms = array_fill_keys($xform_keys, null);
 
       foreach ($files as $file) {
         $file_xforms = idx($xform_map, $file->getPHID(), array());
         $file_xforms += $default_xforms;
         $file->attachTransforms($file_xforms);
       }
     }
 
     return $files;
   }
 
   protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
     $joins = parent::buildJoinClauseParts($conn);
 
     if ($this->transforms) {
       $joins[] = qsprintf(
         $conn,
         'JOIN %T t ON t.transformedPHID = f.phid',
         id(new PhabricatorTransformedFile())->getTableName());
     }
 
     if ($this->shouldJoinAttachmentsTable()) {
       $joins[] = qsprintf(
         $conn,
         'JOIN %R attachments ON attachments.filePHID = f.phid
           AND attachmentMode IN (%Ls)',
         new PhabricatorFileAttachment(),
         array(
           PhabricatorFileAttachment::MODE_ATTACH,
         ));
     }
 
     return $joins;
   }
 
   private function shouldJoinAttachmentsTable() {
     return ($this->attachedObjectPHIDs !== null);
   }
 
   protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
     $where = parent::buildWhereClauseParts($conn);
 
     if ($this->ids !== null) {
       $where[] = qsprintf(
         $conn,
         'f.id IN (%Ld)',
         $this->ids);
     }
 
     if ($this->phids !== null) {
       $where[] = qsprintf(
         $conn,
         'f.phid IN (%Ls)',
         $this->phids);
     }
 
     if ($this->authorPHIDs !== null) {
       $where[] = qsprintf(
         $conn,
         'f.authorPHID IN (%Ls)',
         $this->authorPHIDs);
     }
 
     if ($this->explicitUploads !== null) {
       $where[] = qsprintf(
         $conn,
         'f.isExplicitUpload = %d',
         (int)$this->explicitUploads);
     }
 
     if ($this->transforms !== null) {
       $clauses = array();
       foreach ($this->transforms as $transform) {
         if ($transform['transform'] === true) {
           $clauses[] = qsprintf(
             $conn,
             '(t.originalPHID = %s)',
             $transform['originalPHID']);
         } else {
           $clauses[] = qsprintf(
             $conn,
             '(t.originalPHID = %s AND t.transform = %s)',
             $transform['originalPHID'],
             $transform['transform']);
         }
       }
       $where[] = qsprintf($conn, '%LO', $clauses);
     }
 
     if ($this->dateCreatedAfter !== null) {
       $where[] = qsprintf(
         $conn,
         'f.dateCreated >= %d',
         $this->dateCreatedAfter);
     }
 
     if ($this->dateCreatedBefore !== null) {
       $where[] = qsprintf(
         $conn,
         'f.dateCreated <= %d',
         $this->dateCreatedBefore);
     }
 
     if ($this->contentHashes !== null) {
       $where[] = qsprintf(
         $conn,
         'f.contentHash IN (%Ls)',
         $this->contentHashes);
     }
 
     if ($this->minLength !== null) {
       $where[] = qsprintf(
         $conn,
         'byteSize >= %d',
         $this->minLength);
     }
 
     if ($this->maxLength !== null) {
       $where[] = qsprintf(
         $conn,
         'byteSize <= %d',
         $this->maxLength);
     }
 
     if ($this->names !== null) {
       $where[] = qsprintf(
         $conn,
         'name in (%Ls)',
         $this->names);
     }
 
     if ($this->isPartial !== null) {
       $where[] = qsprintf(
         $conn,
         'isPartial = %d',
         (int)$this->isPartial);
     }
 
     if ($this->isDeleted !== null) {
       $where[] = qsprintf(
         $conn,
         'isDeleted = %d',
         (int)$this->isDeleted);
     }
 
     if ($this->builtinKeys !== null) {
       $where[] = qsprintf(
         $conn,
         'builtinKey IN (%Ls)',
         $this->builtinKeys);
     }
 
     if ($this->isBuiltin !== null) {
       if ($this->isBuiltin) {
         $where[] = qsprintf(
           $conn,
           'builtinKey IS NOT NULL');
       } else {
         $where[] = qsprintf(
           $conn,
           'builtinKey IS NULL');
       }
     }
 
     if ($this->storageEngines !== null) {
       $where[] = qsprintf(
         $conn,
         'storageEngine IN (%Ls)',
         $this->storageEngines);
     }
 
     if ($this->attachedObjectPHIDs !== null) {
       $where[] = qsprintf(
         $conn,
         'attachments.objectPHID IN (%Ls)',
         $this->attachedObjectPHIDs);
     }
 
     return $where;
   }
 
   protected function getPrimaryTableAlias() {
     return 'f';
   }
 
   public function getQueryApplicationClass() {
     return PhabricatorFilesApplication::class;
   }
 
 }
diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php
index ffa29f1919..16681973e0 100644
--- a/src/applications/files/storage/PhabricatorFile.php
+++ b/src/applications/files/storage/PhabricatorFile.php
@@ -1,1852 +1,1853 @@
 <?php
 
 /**
  * Parameters
  * ==========
  *
  * When creating a new file using a method like @{method:newFromFileData}, these
  * parameters are supported:
  *
  *   | name | Human readable filename.
  *   | authorPHID | User PHID of uploader.
  *   | ttl.absolute | Temporary file lifetime as an epoch timestamp.
  *   | ttl.relative | Temporary file lifetime, relative to now, in seconds.
  *   | viewPolicy | File visibility policy.
  *   | isExplicitUpload | Used to show users files they explicitly uploaded.
  *   | canCDN | Allows the file to be cached and delivered over a CDN.
  *   | profile | Marks the file as a profile image.
  *   | format | Internal encoding format.
  *   | mime-type | Optional, explicit file MIME type.
  *   | builtin | Optional filename, identifies this as a builtin.
  *
  */
 final class PhabricatorFile extends PhabricatorFileDAO
   implements
     PhabricatorApplicationTransactionInterface,
     PhabricatorTokenReceiverInterface,
     PhabricatorSubscribableInterface,
     PhabricatorFlaggableInterface,
     PhabricatorPolicyInterface,
     PhabricatorDestructibleInterface,
     PhabricatorConduitResultInterface,
     PhabricatorIndexableInterface,
     PhabricatorNgramsInterface {
 
   const METADATA_IMAGE_WIDTH  = 'width';
   const METADATA_IMAGE_HEIGHT = 'height';
   const METADATA_CAN_CDN = 'canCDN';
   const METADATA_BUILTIN = 'builtin';
   const METADATA_PARTIAL = 'partial';
   const METADATA_PROFILE = 'profile';
   const METADATA_STORAGE = 'storage';
   const METADATA_INTEGRITY = 'integrity';
   const METADATA_CHUNK = 'chunk';
   const METADATA_ALT_TEXT = 'alt';
 
   const STATUS_ACTIVE = 'active';
   const STATUS_DELETED = 'deleted';
 
   protected $name;
   protected $mimeType;
   protected $byteSize;
   protected $authorPHID;
   protected $secretKey;
   protected $contentHash;
   protected $metadata = array();
   protected $mailKey;
   protected $builtinKey;
 
   protected $storageEngine;
   protected $storageFormat;
   protected $storageHandle;
 
   protected $ttl;
   protected $isExplicitUpload = 1;
   protected $viewPolicy = PhabricatorPolicies::POLICY_USER;
   protected $isPartial = 0;
   protected $isDeleted = 0;
 
   private $objects = self::ATTACHABLE;
   private $objectPHIDs = self::ATTACHABLE;
   private $originalFile = self::ATTACHABLE;
   private $transforms = self::ATTACHABLE;
 
   public static function initializeNewFile() {
     $app = id(new PhabricatorApplicationQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withClasses(array('PhabricatorFilesApplication'))
       ->executeOne();
 
     $view_policy = $app->getPolicy(
       FilesDefaultViewCapability::CAPABILITY);
 
     return id(new PhabricatorFile())
       ->setViewPolicy($view_policy)
       ->setIsPartial(0)
       ->attachOriginalFile(null)
       ->attachObjects(array())
       ->attachObjectPHIDs(array());
   }
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_SERIALIZATION => array(
         'metadata' => self::SERIALIZATION_JSON,
       ),
       self::CONFIG_COLUMN_SCHEMA => array(
         'name' => 'sort255?',
         'mimeType' => 'text255?',
         'byteSize' => 'uint64',
         'storageEngine' => 'text32',
         'storageFormat' => 'text32',
         'storageHandle' => 'text255',
         'authorPHID' => 'phid?',
         'secretKey' => 'bytes20?',
         'contentHash' => 'bytes64?',
         'ttl' => 'epoch?',
         'isExplicitUpload' => 'bool?',
         'mailKey' => 'bytes20',
         'isPartial' => 'bool',
         'builtinKey' => 'text64?',
         'isDeleted' => 'bool',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_phid' => null,
         'phid' => array(
           'columns' => array('phid'),
           'unique' => true,
         ),
         'authorPHID' => array(
           'columns' => array('authorPHID'),
         ),
         'contentHash' => array(
           'columns' => array('contentHash'),
         ),
         'key_ttl' => array(
           'columns' => array('ttl'),
         ),
         'key_dateCreated' => array(
           'columns' => array('dateCreated'),
         ),
         'key_partial' => array(
           'columns' => array('authorPHID', 'isPartial'),
         ),
         'key_builtin' => array(
           'columns' => array('builtinKey'),
           'unique' => true,
         ),
         'key_engine' => array(
           'columns' => array('storageEngine', 'storageHandle(64)'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function generatePHID() {
     return PhabricatorPHID::generateNewPHID(
       PhabricatorFileFilePHIDType::TYPECONST);
   }
 
   public function save() {
     if (!$this->getSecretKey()) {
       $this->setSecretKey($this->generateSecretKey());
     }
     if (!$this->getMailKey()) {
       $this->setMailKey(Filesystem::readRandomCharacters(20));
     }
     return parent::save();
   }
 
   public function saveAndIndex() {
     $this->save();
 
     if ($this->isIndexableFile()) {
       PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID());
     }
 
     return $this;
   }
 
   private function isIndexableFile() {
     if ($this->getIsChunk()) {
       return false;
     }
 
     return true;
   }
 
   /**
    * Get file monogram in the format of "F123"
    * @return string
    */
   public function getMonogram() {
     return 'F'.$this->getID();
   }
 
   public function scrambleSecret() {
     return $this->setSecretKey($this->generateSecretKey());
   }
 
   public static function readUploadedFileData($spec) {
     if (!$spec) {
       throw new Exception(pht('No file was uploaded!'));
     }
 
     $err = idx($spec, 'error');
     if ($err) {
       throw new PhabricatorFileUploadException($err);
     }
 
     $tmp_name = idx($spec, 'tmp_name');
 
     // NOTE: If we parsed the request body ourselves, the files we wrote will
     // not be registered in the `is_uploaded_file()` list. It's fine to skip
     // this check: it just protects against sloppy code from the long ago era
     // of "register_globals".
 
     if (ini_get('enable_post_data_reading')) {
       $is_valid = @is_uploaded_file($tmp_name);
       if (!$is_valid) {
         throw new Exception(pht('File is not an uploaded file.'));
       }
     }
 
     $file_data = Filesystem::readFile($tmp_name);
     $file_size = idx($spec, 'size');
 
     if (strlen($file_data) != $file_size) {
       throw new Exception(pht('File size disagrees with uploaded size.'));
     }
 
     return $file_data;
   }
 
   public static function newFromPHPUpload($spec, array $params = array()) {
     $file_data = self::readUploadedFileData($spec);
 
     $file_name = nonempty(
       idx($params, 'name'),
       idx($spec,   'name'));
     $params = array(
       'name' => $file_name,
     ) + $params;
 
     return self::newFromFileData($file_data, $params);
   }
 
   public static function newFromXHRUpload($data, array $params = array()) {
     return self::newFromFileData($data, $params);
   }
 
 
   public static function newFileFromContentHash($hash, array $params) {
     if ($hash === null) {
       return null;
     }
 
     // Check to see if a file with same hash already exists.
     $file = id(new PhabricatorFile())->loadOneWhere(
       'contentHash = %s LIMIT 1',
       $hash);
     if (!$file) {
       return null;
     }
 
     $copy_of_storage_engine = $file->getStorageEngine();
     $copy_of_storage_handle = $file->getStorageHandle();
     $copy_of_storage_format = $file->getStorageFormat();
     $copy_of_storage_properties = $file->getStorageProperties();
     $copy_of_byte_size = $file->getByteSize();
     $copy_of_mime_type = $file->getMimeType();
 
     $new_file = self::initializeNewFile();
 
     $new_file->setByteSize($copy_of_byte_size);
 
     $new_file->setContentHash($hash);
     $new_file->setStorageEngine($copy_of_storage_engine);
     $new_file->setStorageHandle($copy_of_storage_handle);
     $new_file->setStorageFormat($copy_of_storage_format);
     $new_file->setStorageProperties($copy_of_storage_properties);
     $new_file->setMimeType($copy_of_mime_type);
     $new_file->copyDimensions($file);
 
     $new_file->readPropertiesFromParameters($params);
 
     $new_file->saveAndIndex();
 
     return $new_file;
   }
 
   public static function newChunkedFile(
     PhabricatorFileStorageEngine $engine,
     $length,
     array $params) {
 
     $file = self::initializeNewFile();
 
     $file->setByteSize($length);
 
     // NOTE: Once we receive the first chunk, we'll detect its MIME type and
     // update the parent file if a MIME type hasn't been provided. This matters
     // for large media files like video.
     $mime_type = idx($params, 'mime-type', '');
     if (!strlen($mime_type)) {
       $file->setMimeType('application/octet-stream');
     }
 
     $chunked_hash = idx($params, 'chunkedHash');
 
     // Get rid of this parameter now; we aren't passing it any further down
     // the stack.
     unset($params['chunkedHash']);
 
     if ($chunked_hash) {
       $file->setContentHash($chunked_hash);
     } else {
       // See PhabricatorChunkedFileStorageEngine::getChunkedHash() for some
       // discussion of this.
       $seed = Filesystem::readRandomBytes(64);
       $hash = PhabricatorChunkedFileStorageEngine::getChunkedHashForInput(
         $seed);
       $file->setContentHash($hash);
     }
 
     $file->setStorageEngine($engine->getEngineIdentifier());
     $file->setStorageHandle(PhabricatorFileChunk::newChunkHandle());
 
     // Chunked files are always stored raw because they do not actually store
     // data. The chunks do, and can be individually formatted.
     $file->setStorageFormat(PhabricatorFileRawStorageFormat::FORMATKEY);
 
     $file->setIsPartial(1);
 
     $file->readPropertiesFromParameters($params);
 
     return $file;
   }
 
   private static function buildFromFileData($data, array $params = array()) {
 
     if (isset($params['storageEngines'])) {
       $engines = $params['storageEngines'];
     } else {
       $size = strlen($data);
       $engines = PhabricatorFileStorageEngine::loadStorageEngines($size);
 
       if (!$engines) {
         throw new Exception(
           pht(
             'No configured storage engine can store this file. See '.
             '"Configuring File Storage" in the documentation for '.
             'information on configuring storage engines.'));
       }
     }
 
     assert_instances_of($engines, 'PhabricatorFileStorageEngine');
     if (!$engines) {
       throw new Exception(pht('No valid storage engines are available!'));
     }
 
     $file = self::initializeNewFile();
 
     $aes_type = PhabricatorFileAES256StorageFormat::FORMATKEY;
     $has_aes = PhabricatorKeyring::getDefaultKeyName($aes_type);
     if ($has_aes !== null) {
       $default_key = PhabricatorFileAES256StorageFormat::FORMATKEY;
     } else {
       $default_key = PhabricatorFileRawStorageFormat::FORMATKEY;
     }
     $key = idx($params, 'format', $default_key);
 
     // Callers can pass in an object explicitly instead of a key. This is
     // primarily useful for unit tests.
     if ($key instanceof PhabricatorFileStorageFormat) {
       $format = clone $key;
     } else {
       $format = clone PhabricatorFileStorageFormat::requireFormat($key);
     }
 
     $format->setFile($file);
 
     $properties = $format->newStorageProperties();
     $file->setStorageFormat($format->getStorageFormatKey());
     $file->setStorageProperties($properties);
 
     $data_handle = null;
     $engine_identifier = null;
     $integrity_hash = null;
     $exceptions = array();
     foreach ($engines as $engine) {
       $engine_class = get_class($engine);
       try {
         $result = $file->writeToEngine(
           $engine,
           $data,
           $params);
 
         list($engine_identifier, $data_handle, $integrity_hash) = $result;
 
         // We stored the file somewhere so stop trying to write it to other
         // places.
         break;
       } catch (PhabricatorFileStorageConfigurationException $ex) {
         // If an engine is outright misconfigured (or misimplemented), raise
         // that immediately since it probably needs attention.
         throw $ex;
       } catch (Exception $ex) {
         phlog($ex);
 
         // If an engine doesn't work, keep trying all the other valid engines
         // in case something else works.
         $exceptions[$engine_class] = $ex;
       }
     }
 
     if (!$data_handle) {
       throw new PhutilAggregateException(
         pht('All storage engines failed to write file:'),
         $exceptions);
     }
 
     $file->setByteSize(strlen($data));
 
     $hash = self::hashFileContent($data);
     $file->setContentHash($hash);
 
     $file->setStorageEngine($engine_identifier);
     $file->setStorageHandle($data_handle);
 
     $file->setIntegrityHash($integrity_hash);
 
     $file->readPropertiesFromParameters($params);
 
     if (!$file->getMimeType()) {
       $tmp = new TempFile();
       Filesystem::writeFile($tmp, $data);
       $file->setMimeType(Filesystem::getMimeType($tmp));
       unset($tmp);
     }
 
     try {
       $file->updateDimensions(false);
     } catch (Exception $ex) {
       // Do nothing.
     }
 
     $file->saveAndIndex();
 
     return $file;
   }
 
   public static function newFromFileData($data, array $params = array()) {
     $hash = self::hashFileContent($data);
 
     if ($hash !== null) {
       $file = self::newFileFromContentHash($hash, $params);
       if ($file) {
         return $file;
       }
     }
 
     return self::buildFromFileData($data, $params);
   }
 
   public function migrateToEngine(
     PhabricatorFileStorageEngine $engine,
     $make_copy) {
 
     if (!$this->getID() || !$this->getStorageHandle()) {
       throw new Exception(
         pht("You can not migrate a file which hasn't yet been saved."));
     }
 
     $data = $this->loadFileData();
     $params = array(
       'name' => $this->getName(),
     );
 
     list($new_identifier, $new_handle, $integrity_hash) = $this->writeToEngine(
       $engine,
       $data,
       $params);
 
     $old_engine = $this->instantiateStorageEngine();
     $old_identifier = $this->getStorageEngine();
     $old_handle = $this->getStorageHandle();
 
     $this->setStorageEngine($new_identifier);
     $this->setStorageHandle($new_handle);
     $this->setIntegrityHash($integrity_hash);
     $this->save();
 
     if (!$make_copy) {
       $this->deleteFileDataIfUnused(
         $old_engine,
         $old_identifier,
         $old_handle);
     }
 
     return $this;
   }
 
   public function migrateToStorageFormat(PhabricatorFileStorageFormat $format) {
     if (!$this->getID() || !$this->getStorageHandle()) {
       throw new Exception(
         pht("You can not migrate a file which hasn't yet been saved."));
     }
 
     $data = $this->loadFileData();
     $params = array(
       'name' => $this->getName(),
     );
 
     $engine = $this->instantiateStorageEngine();
     $old_handle = $this->getStorageHandle();
 
     $properties = $format->newStorageProperties();
     $this->setStorageFormat($format->getStorageFormatKey());
     $this->setStorageProperties($properties);
 
     list($identifier, $new_handle, $integrity_hash) = $this->writeToEngine(
       $engine,
       $data,
       $params);
 
     $this->setStorageHandle($new_handle);
     $this->setIntegrityHash($integrity_hash);
     $this->save();
 
     $this->deleteFileDataIfUnused(
       $engine,
       $identifier,
       $old_handle);
 
     return $this;
   }
 
   public function cycleMasterStorageKey(PhabricatorFileStorageFormat $format) {
     if (!$this->getID() || !$this->getStorageHandle()) {
       throw new Exception(
         pht("You can not cycle keys for a file which hasn't yet been saved."));
     }
 
     $properties = $format->cycleStorageProperties();
     $this->setStorageProperties($properties);
     $this->save();
 
     return $this;
   }
 
   private function writeToEngine(
     PhabricatorFileStorageEngine $engine,
     $data,
     array $params) {
 
     $engine_class = get_class($engine);
 
     $format = $this->newStorageFormat();
 
     $data_iterator = array($data);
     $formatted_iterator = $format->newWriteIterator($data_iterator);
     $formatted_data = $this->loadDataFromIterator($formatted_iterator);
 
     $integrity_hash = $engine->newIntegrityHash($formatted_data, $format);
 
     $data_handle = $engine->writeFile($formatted_data, $params);
 
     if (!$data_handle || strlen($data_handle) > 255) {
       // This indicates an improperly implemented storage engine.
       throw new PhabricatorFileStorageConfigurationException(
         pht(
           "Storage engine '%s' executed %s but did not return a valid ".
           "handle ('%s') to the data: it must be nonempty and no longer ".
           "than 255 characters.",
           $engine_class,
           'writeFile()',
           $data_handle));
     }
 
     $engine_identifier = $engine->getEngineIdentifier();
     if (!$engine_identifier || strlen($engine_identifier) > 32) {
       throw new PhabricatorFileStorageConfigurationException(
         pht(
           "Storage engine '%s' returned an improper engine identifier '{%s}': ".
           "it must be nonempty and no longer than 32 characters.",
           $engine_class,
           $engine_identifier));
     }
 
     return array($engine_identifier, $data_handle, $integrity_hash);
   }
 
 
   /**
    * Download a remote resource over HTTP and save the response body as a file.
    *
    * This method respects `security.outbound-blacklist`, and protects against
    * HTTP redirection (by manually following "Location" headers and verifying
    * each destination). It does not protect against DNS rebinding. See
    * discussion in T6755.
    */
   public static function newFromFileDownload($uri, array $params = array()) {
     $timeout = 5;
 
     $redirects = array();
     $current = $uri;
     while (true) {
       try {
         if (count($redirects) > 10) {
           throw new Exception(
             pht('Too many redirects trying to fetch remote URI.'));
         }
 
         $resolved = PhabricatorEnv::requireValidRemoteURIForFetch(
           $current,
           array(
             'http',
             'https',
           ));
 
         list($resolved_uri, $resolved_domain) = $resolved;
 
         $current = new PhutilURI($current);
         if ($current->getProtocol() == 'http') {
           // For HTTP, we can use a pre-resolved URI to defuse DNS rebinding.
           $fetch_uri = $resolved_uri;
           $fetch_host = $resolved_domain;
         } else {
           // For HTTPS, we can't: cURL won't verify the SSL certificate if
           // the domain has been replaced with an IP. But internal services
           // presumably will not have valid certificates for rebindable
           // domain names on attacker-controlled domains, so the DNS rebinding
           // attack should generally not be possible anyway.
           $fetch_uri = $current;
           $fetch_host = null;
         }
 
         $future = id(new HTTPSFuture($fetch_uri))
           ->setFollowLocation(false)
           ->setTimeout($timeout);
 
         if ($fetch_host !== null) {
           $future->addHeader('Host', $fetch_host);
         }
 
         list($status, $body, $headers) = $future->resolve();
 
         if ($status->isRedirect()) {
           // This is an HTTP 3XX status, so look for a "Location" header.
           $location = null;
           foreach ($headers as $header) {
             list($name, $value) = $header;
             if (phutil_utf8_strtolower($name) == 'location') {
               $location = $value;
               break;
             }
           }
 
           // HTTP 3XX status with no "Location" header, just treat this like
           // a normal HTTP error.
           if ($location === null) {
             throw $status;
           }
 
           if (isset($redirects[$location])) {
             throw new Exception(
               pht('Encountered loop while following redirects.'));
           }
 
           $redirects[$location] = $location;
           $current = $location;
           // We'll fall off the bottom and go try this URI now.
         } else if ($status->isError()) {
           // This is something other than an HTTP 2XX or HTTP 3XX status, so
           // just bail out.
           throw $status;
         } else {
           // This is HTTP 2XX, so use the response body to save the file data.
           // Provide a default name based on the URI, truncating it if the URI
           // is exceptionally long.
 
           $default_name = basename($uri);
           $default_name = id(new PhutilUTF8StringTruncator())
             ->setMaximumBytes(64)
             ->truncateString($default_name);
 
           $params = $params + array(
             'name' => $default_name,
           );
 
           return self::newFromFileData($body, $params);
         }
       } catch (Exception $ex) {
         if ($redirects) {
           throw new PhutilProxyException(
             pht(
               'Failed to fetch remote URI "%s" after following %s redirect(s) '.
               '(%s): %s',
               $uri,
               phutil_count($redirects),
               implode(' > ', array_keys($redirects)),
               $ex->getMessage()),
             $ex);
         } else {
           throw $ex;
         }
       }
     }
   }
 
   public static function normalizeFileName($file_name) {
     $pattern = "@[\\x00-\\x19#%&+!~'\$\"\/=\\\\?<> ]+@";
     $file_name = preg_replace($pattern, '_', $file_name);
     $file_name = preg_replace('@_+@', '_', $file_name);
     $file_name = trim($file_name, '_');
 
     $disallowed_filenames = array(
       '.'  => 'dot',
       '..' => 'dotdot',
       ''   => 'file',
     );
     $file_name = idx($disallowed_filenames, $file_name, $file_name);
 
     return $file_name;
   }
 
   public function delete() {
     // We want to delete all the rows which mark this file as the transformation
     // of some other file (since we're getting rid of it). We also delete all
     // the transformations of this file, so that a user who deletes an image
     // doesn't need to separately hunt down and delete a bunch of thumbnails and
     // resizes of it.
 
     $outbound_xforms = id(new PhabricatorFileQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withTransforms(
         array(
           array(
             'originalPHID' => $this->getPHID(),
             'transform'    => true,
           ),
         ))
       ->execute();
 
     foreach ($outbound_xforms as $outbound_xform) {
       $outbound_xform->delete();
     }
 
     $inbound_xforms = id(new PhabricatorTransformedFile())->loadAllWhere(
       'transformedPHID = %s',
       $this->getPHID());
 
     $this->openTransaction();
       foreach ($inbound_xforms as $inbound_xform) {
         $inbound_xform->delete();
       }
       $ret = parent::delete();
     $this->saveTransaction();
 
     $this->deleteFileDataIfUnused(
       $this->instantiateStorageEngine(),
       $this->getStorageEngine(),
       $this->getStorageHandle());
 
     return $ret;
   }
 
 
   /**
    * Destroy stored file data if there are no remaining files which reference
    * it.
    */
   public function deleteFileDataIfUnused(
     PhabricatorFileStorageEngine $engine,
     $engine_identifier,
     $handle) {
 
     // Check to see if any files are using storage.
     $usage = id(new PhabricatorFile())->loadAllWhere(
       'storageEngine = %s AND storageHandle = %s LIMIT 1',
       $engine_identifier,
       $handle);
 
     // If there are no files using the storage, destroy the actual storage.
     if (!$usage) {
       try {
         $engine->deleteFile($handle);
       } catch (Exception $ex) {
         // In the worst case, we're leaving some data stranded in a storage
         // engine, which is not a big deal.
         phlog($ex);
       }
     }
   }
 
   public static function hashFileContent($data) {
     // NOTE: Hashing can fail if the algorithm isn't available in the current
     // build of PHP. It's fine if we're unable to generate a content hash:
     // it just means we'll store extra data when users upload duplicate files
     // instead of being able to deduplicate it.
 
     $hash = hash('sha256', $data, $raw_output = false);
     if ($hash === false) {
       return null;
     }
 
     return $hash;
   }
 
   public function loadFileData() {
     $iterator = $this->getFileDataIterator();
     return $this->loadDataFromIterator($iterator);
   }
 
 
   /**
    * Return an iterable which emits file content bytes.
    *
-   * @param int Offset for the start of data.
-   * @param int Offset for the end of data.
+   * @param int? $begin Offset for the start of data.
+   * @param int? $end Offset for the end of data.
    * @return Iterable Iterable object which emits requested data.
    */
   public function getFileDataIterator($begin = null, $end = null) {
     $engine = $this->instantiateStorageEngine();
 
     $format = $this->newStorageFormat();
 
     $iterator = $engine->getRawFileDataIterator(
       $this,
       $begin,
       $end,
       $format);
 
     return $iterator;
   }
 
   /**
    * Get file URI in the format of "/F123"
    * @return string
    */
   public function getURI() {
     return $this->getInfoURI();
   }
 
   /**
    * Get file view URI in the format of
    * https://phorge.example.com/file/data/foo/PHID-FILE-bar/filename
    * @return string
    */
   public function getViewURI() {
     if (!$this->getPHID()) {
       throw new Exception(
         pht('You must save a file before you can generate a view URI.'));
     }
 
     return $this->getCDNURI('data');
   }
 
   /**
    * Get file view URI in the format of
    * https://phorge.example.com/file/data/foo/PHID-FILE-bar/filename or
    * https://phorge.example.com/file/download/foo/PHID-FILE-bar/filename
    * @return string
    */
   public function getCDNURI($request_kind) {
     if (($request_kind !== 'data') &&
         ($request_kind !== 'download')) {
       throw new Exception(
         pht(
           'Unknown file content request kind "%s".',
           $request_kind));
     }
 
     $name = self::normalizeFileName($this->getName());
     $name = phutil_escape_uri($name);
 
     $parts = array();
     $parts[] = 'file';
     $parts[] = $request_kind;
 
     // If this is an instanced install, add the instance identifier to the URI.
     // Instanced configurations behind a CDN may not be able to control the
     // request domain used by the CDN (as with AWS CloudFront). Embedding the
     // instance identity in the path allows us to distinguish between requests
     // originating from different instances but served through the same CDN.
     $instance = PhabricatorEnv::getEnvConfig('cluster.instance');
     if (phutil_nonempty_string($instance)) {
       $parts[] = '@'.$instance;
     }
 
     $parts[] = $this->getSecretKey();
     $parts[] = $this->getPHID();
     $parts[] = $name;
 
     $path = '/'.implode('/', $parts);
 
     // If this file is only partially uploaded, we're just going to return a
     // local URI to make sure that Ajax works, since the page is inevitably
     // going to give us an error back.
     if ($this->getIsPartial()) {
       return PhabricatorEnv::getURI($path);
     } else {
       return PhabricatorEnv::getCDNURI($path);
     }
   }
 
   /**
    * Get file info URI in the format of "/F123"
    * @return string
    */
   public function getInfoURI() {
     return '/'.$this->getMonogram();
   }
 
   public function getBestURI() {
     if ($this->isViewableInBrowser()) {
       return $this->getViewURI();
     } else {
       return $this->getInfoURI();
     }
   }
 
   /**
    * Get file view URI in the format of
    * https://phorge.example.com/file/download/foo/PHID-FILE-bar/filename
    * @return string
    */
   public function getDownloadURI() {
     return $this->getCDNURI('download');
   }
 
   public function getURIForTransform(PhabricatorFileTransform $transform) {
     return $this->getTransformedURI($transform->getTransformKey());
   }
 
   private function getTransformedURI($transform) {
     $parts = array();
     $parts[] = 'file';
     $parts[] = 'xform';
 
     $instance = PhabricatorEnv::getEnvConfig('cluster.instance');
     if (phutil_nonempty_string($instance)) {
       $parts[] = '@'.$instance;
     }
 
     $parts[] = $transform;
     $parts[] = $this->getPHID();
     $parts[] = $this->getSecretKey();
 
     $path = implode('/', $parts);
     $path = $path.'/';
 
     return PhabricatorEnv::getCDNURI($path);
   }
 
   /**
    * Whether the file can be viewed in a browser
    * @return bool True if MIME type of the file is listed in the
    * files.viewable-mime-types setting
    */
   public function isViewableInBrowser() {
     return ($this->getViewableMimeType() !== null);
   }
 
   /**
    * Whether the file is an image viewable in the browser
    * @return bool True if MIME type of the file is listed in the
    * files.image-mime-types setting and file is viewable in the browser
    */
   public function isViewableImage() {
     if (!$this->isViewableInBrowser()) {
       return false;
     }
 
     $mime_map = PhabricatorEnv::getEnvConfig('files.image-mime-types');
     $mime_type = $this->getMimeType();
     return idx($mime_map, $mime_type);
   }
 
   /**
    * Whether the file is an audio file
    * @return bool True if MIME type of the file is listed in the
    * files.audio-mime-types setting and file is viewable in the browser
    */
   public function isAudio() {
     if (!$this->isViewableInBrowser()) {
       return false;
     }
 
     $mime_map = PhabricatorEnv::getEnvConfig('files.audio-mime-types');
     $mime_type = $this->getMimeType();
     return idx($mime_map, $mime_type);
   }
 
   /**
    * Whether the file is a video file
    * @return bool True if MIME type of the file is listed in the
    * files.video-mime-types setting and file is viewable in the browser
    */
   public function isVideo() {
     if (!$this->isViewableInBrowser()) {
       return false;
     }
 
     $mime_map = PhabricatorEnv::getEnvConfig('files.video-mime-types');
     $mime_type = $this->getMimeType();
     return idx($mime_map, $mime_type);
   }
 
   /**
    * Whether the file is a PDF file
    * @return bool True if MIME type of the file is application/pdf and file is
    * viewable in the browser
    */
   public function isPDF() {
     if (!$this->isViewableInBrowser()) {
       return false;
     }
 
     $mime_map = array(
       'application/pdf' => 'application/pdf',
     );
 
     $mime_type = $this->getMimeType();
     return idx($mime_map, $mime_type);
   }
 
   public function isTransformableImage() {
     // NOTE: The way the 'gd' extension works in PHP is that you can install it
     // with support for only some file types, so it might be able to handle
     // PNG but not JPEG. Try to generate thumbnails for whatever we can. Setup
     // warns you if you don't have complete support.
 
     $matches = null;
     $ok = false;
     if ($this->getViewableMimeType() !== null) {
       $ok = preg_match(
         '@^image/(gif|png|jpe?g)@',
         $this->getViewableMimeType(),
         $matches);
     }
     if (!$ok) {
       return false;
     }
 
     switch ($matches[1]) {
       case 'jpg';
       case 'jpeg':
         return function_exists('imagejpeg');
         break;
       case 'png':
         return function_exists('imagepng');
         break;
       case 'gif':
         return function_exists('imagegif');
         break;
       default:
         throw new Exception(pht('Unknown type matched as image MIME type.'));
     }
   }
 
   public static function getTransformableImageFormats() {
     $supported = array();
 
     if (function_exists('imagejpeg')) {
       $supported[] = 'jpg';
     }
 
     if (function_exists('imagepng')) {
       $supported[] = 'png';
     }
 
     if (function_exists('imagegif')) {
       $supported[] = 'gif';
     }
 
     return $supported;
   }
 
   public function getDragAndDropDictionary() {
     return array(
       'id'   => $this->getID(),
       'phid' => $this->getPHID(),
       'uri'  => $this->getBestURI(),
     );
   }
 
   public function instantiateStorageEngine() {
     return self::buildEngine($this->getStorageEngine());
   }
 
   public static function buildEngine($engine_identifier) {
     $engines = self::buildAllEngines();
     foreach ($engines as $engine) {
       if ($engine->getEngineIdentifier() == $engine_identifier) {
         return $engine;
       }
     }
 
     throw new Exception(
       pht(
         "Storage engine '%s' could not be located!",
         $engine_identifier));
   }
 
   public static function buildAllEngines() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass('PhabricatorFileStorageEngine')
       ->execute();
   }
 
   /**
    * Whether the file is listed as a viewable MIME type
    * @return bool True if MIME type of the file is listed in the
    * files.viewable-mime-types setting
    */
   public function getViewableMimeType() {
     $mime_map = PhabricatorEnv::getEnvConfig('files.viewable-mime-types');
 
     $mime_type = $this->getMimeType();
     $mime_parts = explode(';', $mime_type);
     $mime_type = trim(reset($mime_parts));
 
     return idx($mime_map, $mime_type);
   }
 
   public function getDisplayIconForMimeType() {
     $mime_map = PhabricatorEnv::getEnvConfig('files.icon-mime-types');
     $mime_type = $this->getMimeType();
     return idx($mime_map, $mime_type, 'fa-file-o');
   }
 
   public function validateSecretKey($key) {
     return ($key == $this->getSecretKey());
   }
 
   public function generateSecretKey() {
     return Filesystem::readRandomCharacters(20);
   }
 
   public function setStorageProperties(array $properties) {
     $this->metadata[self::METADATA_STORAGE] = $properties;
     return $this;
   }
 
   public function getStorageProperties() {
     return idx($this->metadata, self::METADATA_STORAGE, array());
   }
 
   public function getStorageProperty($key, $default = null) {
     $properties = $this->getStorageProperties();
     return idx($properties, $key, $default);
   }
 
   public function loadDataFromIterator($iterator) {
     $result = '';
 
     foreach ($iterator as $chunk) {
       $result .= $chunk;
     }
 
     return $result;
   }
 
   public function updateDimensions($save = true) {
     if (!$this->isViewableImage()) {
       throw new Exception(pht('This file is not a viewable image.'));
     }
 
     if (!function_exists('imagecreatefromstring')) {
       throw new Exception(pht('Cannot retrieve image information.'));
     }
 
     if ($this->getIsChunk()) {
       throw new Exception(
         pht('Refusing to assess image dimensions of file chunk.'));
     }
 
     $engine = $this->instantiateStorageEngine();
     if ($engine->isChunkEngine()) {
       throw new Exception(
         pht('Refusing to assess image dimensions of chunked file.'));
     }
 
     $data = $this->loadFileData();
 
     $img = @imagecreatefromstring($data);
     if ($img === false) {
       throw new Exception(pht('Error when decoding image.'));
     }
 
     $this->metadata[self::METADATA_IMAGE_WIDTH] = imagesx($img);
     $this->metadata[self::METADATA_IMAGE_HEIGHT] = imagesy($img);
 
     if ($save) {
       $this->save();
     }
 
     return $this;
   }
 
   public function copyDimensions(PhabricatorFile $file) {
     $metadata = $file->getMetadata();
     $width = idx($metadata, self::METADATA_IMAGE_WIDTH);
     if ($width) {
       $this->metadata[self::METADATA_IMAGE_WIDTH] = $width;
     }
     $height = idx($metadata, self::METADATA_IMAGE_HEIGHT);
     if ($height) {
       $this->metadata[self::METADATA_IMAGE_HEIGHT] = $height;
     }
 
     return $this;
   }
 
 
   /**
    * Load (or build) the {@class:PhabricatorFile} objects for builtin file
    * resources. The builtin mechanism allows files shipped with Phabricator
    * to be treated like normal files so that APIs do not need to special case
    * things like default images or deleted files.
    *
    * Builtins are located in `resources/builtin/` and identified by their
    * name.
    *
-   * @param  PhabricatorUser Viewing user.
-   * @param  list<PhabricatorFilesBuiltinFile> List of builtin file specs.
+   * @param  PhabricatorUser $user Viewing user.
+   * @param  list<PhabricatorFilesBuiltinFile> $builtins List of builtin file
+   *   specs.
    * @return dict<string, PhabricatorFile> Dictionary of named builtins.
    */
   public static function loadBuiltins(PhabricatorUser $user, array $builtins) {
     $builtins = mpull($builtins, null, 'getBuiltinFileKey');
 
     // NOTE: Anyone is allowed to access builtin files.
 
     $files = id(new PhabricatorFileQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withBuiltinKeys(array_keys($builtins))
       ->execute();
 
     $results = array();
     foreach ($files as $file) {
       $builtin_key = $file->getBuiltinName();
       if ($builtin_key !== null) {
         $results[$builtin_key] = $file;
       }
     }
 
     $build = array();
     foreach ($builtins as $key => $builtin) {
       if (isset($results[$key])) {
         continue;
       }
 
       $data = $builtin->loadBuiltinFileData();
 
       $params = array(
         'name' => $builtin->getBuiltinDisplayName(),
         'canCDN' => true,
         'builtin' => $key,
       );
 
       $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
         try {
           $file = self::newFromFileData($data, $params);
         } catch (AphrontDuplicateKeyQueryException $ex) {
           $file = id(new PhabricatorFileQuery())
             ->setViewer(PhabricatorUser::getOmnipotentUser())
             ->withBuiltinKeys(array($key))
             ->executeOne();
           if (!$file) {
             throw new Exception(
               pht(
                 'Collided mid-air when generating builtin file "%s", but '.
                 'then failed to load the object we collided with.',
                 $key));
           }
         }
       unset($unguarded);
 
       $file->attachObjectPHIDs(array());
       $file->attachObjects(array());
 
       $results[$key] = $file;
     }
 
     return $results;
   }
 
 
   /**
    * Convenience wrapper for @{method:loadBuiltins}.
    *
-   * @param PhabricatorUser   Viewing user.
-   * @param string            Single builtin name to load.
+   * @param PhabricatorUser   $user Viewing user.
+   * @param string            $name Single builtin name to load.
    * @return PhabricatorFile  Corresponding builtin file.
    */
   public static function loadBuiltin(PhabricatorUser $user, $name) {
     $builtin = id(new PhabricatorFilesOnDiskBuiltinFile())
       ->setName($name);
 
     $key = $builtin->getBuiltinFileKey();
 
     return idx(self::loadBuiltins($user, array($builtin)), $key);
   }
 
   public function getObjects() {
     return $this->assertAttached($this->objects);
   }
 
   public function attachObjects(array $objects) {
     $this->objects = $objects;
     return $this;
   }
 
   public function getObjectPHIDs() {
     return $this->assertAttached($this->objectPHIDs);
   }
 
   public function attachObjectPHIDs(array $object_phids) {
     $this->objectPHIDs = $object_phids;
     return $this;
   }
 
   public function getOriginalFile() {
     return $this->assertAttached($this->originalFile);
   }
 
   public function attachOriginalFile(PhabricatorFile $file = null) {
     $this->originalFile = $file;
     return $this;
   }
 
   public function getImageHeight() {
     if (!$this->isViewableImage()) {
       return null;
     }
     return idx($this->metadata, self::METADATA_IMAGE_HEIGHT);
   }
 
   public function getImageWidth() {
     if (!$this->isViewableImage()) {
       return null;
     }
     return idx($this->metadata, self::METADATA_IMAGE_WIDTH);
   }
 
   public function getAltText() {
     $alt = $this->getCustomAltText();
 
     if (phutil_nonempty_string($alt)) {
       return $alt;
     }
 
     return $this->getDefaultAltText();
   }
 
   public function getCustomAltText() {
     return idx($this->metadata, self::METADATA_ALT_TEXT);
   }
 
   public function setCustomAltText($value) {
     $value = phutil_string_cast($value);
 
     if (!strlen($value)) {
       $value = null;
     }
 
     if ($value === null) {
       unset($this->metadata[self::METADATA_ALT_TEXT]);
     } else {
       $this->metadata[self::METADATA_ALT_TEXT] = $value;
     }
 
     return $this;
   }
 
   public function getDefaultAltText() {
     $parts = array();
 
     $name = $this->getName();
     if (strlen($name)) {
       $parts[] = $name;
     }
 
     $stats = array();
 
     $image_x = $this->getImageHeight();
     $image_y = $this->getImageWidth();
 
     if ($image_x && $image_y) {
       $stats[] = pht(
         "%d\xC3\x97%d px",
         new PhutilNumber($image_x),
         new PhutilNumber($image_y));
     }
 
     $bytes = $this->getByteSize();
     if ($bytes) {
       $stats[] = phutil_format_bytes($bytes);
     }
 
     if ($stats) {
       $parts[] = pht('(%s)', implode(', ', $stats));
     }
 
     if (!$parts) {
       return null;
     }
 
     return implode(' ', $parts);
   }
 
   public function getCanCDN() {
     if (!$this->isViewableImage()) {
       return false;
     }
 
     return idx($this->metadata, self::METADATA_CAN_CDN);
   }
 
   public function setCanCDN($can_cdn) {
     $this->metadata[self::METADATA_CAN_CDN] = $can_cdn ? 1 : 0;
     return $this;
   }
 
   public function isBuiltin() {
     return ($this->getBuiltinName() !== null);
   }
 
   public function getBuiltinName() {
     return idx($this->metadata, self::METADATA_BUILTIN);
   }
 
   public function setBuiltinName($name) {
     $this->metadata[self::METADATA_BUILTIN] = $name;
     return $this;
   }
 
   public function getIsProfileImage() {
     return idx($this->metadata, self::METADATA_PROFILE);
   }
 
   public function setIsProfileImage($value) {
     $this->metadata[self::METADATA_PROFILE] = $value;
     return $this;
   }
 
   public function getIsChunk() {
     return idx($this->metadata, self::METADATA_CHUNK);
   }
 
   public function setIsChunk($value) {
     $this->metadata[self::METADATA_CHUNK] = $value;
     return $this;
   }
 
   public function setIntegrityHash($integrity_hash) {
     $this->metadata[self::METADATA_INTEGRITY] = $integrity_hash;
     return $this;
   }
 
   public function getIntegrityHash() {
     return idx($this->metadata, self::METADATA_INTEGRITY);
   }
 
   public function newIntegrityHash() {
     $engine = $this->instantiateStorageEngine();
 
     if ($engine->isChunkEngine()) {
       return null;
     }
 
     $format = $this->newStorageFormat();
 
     $storage_handle = $this->getStorageHandle();
     $data = $engine->readFile($storage_handle);
 
     return $engine->newIntegrityHash($data, $format);
   }
 
   /**
    * Write the policy edge between this file and some object.
    * This method is successful even if the file is already attached.
    *
-   * @param phid Object PHID to attach to.
+   * @param phid $phid Object PHID to attach to.
    * @return this
    */
   public function attachToObject($phid) {
     self::attachFileToObject($this->getPHID(), $phid);
     return $this;
   }
 
   /**
    * Write the policy edge between a file and some object.
    * This method is successful even if the file is already attached.
    * NOTE: Please avoid to use this static method directly.
    *       Instead, use PhabricatorFile#attachToObject(phid).
    *
-   * @param phid File PHID to attach from.
-   * @param phid Object PHID to attach to.
+   * @param phid $file_phid File PHID to attach from.
+   * @param phid $object_phid Object PHID to attach to.
    * @return void
    */
   public static function attachFileToObject($file_phid, $object_phid) {
 
     // It can be easy to confuse the two arguments. Be strict.
     if (phid_get_type($file_phid) !== PhabricatorFileFilePHIDType::TYPECONST) {
       throw new Exception(pht('The first argument must be a phid of a file.'));
     }
 
     $attachment_table = new PhabricatorFileAttachment();
     $attachment_conn = $attachment_table->establishConnection('w');
 
     queryfx(
       $attachment_conn,
       'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
           attacherPHID, dateCreated, dateModified)
         VALUES (%s, %s, %s, %ns, %d, %d)
         ON DUPLICATE KEY UPDATE
           attachmentMode = VALUES(attachmentMode),
           attacherPHID = VALUES(attacherPHID),
           dateModified = VALUES(dateModified)',
       $attachment_table,
       $object_phid,
       $file_phid,
       PhabricatorFileAttachment::MODE_ATTACH,
       null,
       PhabricatorTime::getNow(),
       PhabricatorTime::getNow());
   }
 
 
   /**
    * Configure a newly created file object according to specified parameters.
    *
    * This method is called both when creating a file from fresh data, and
    * when creating a new file which reuses existing storage.
    *
-   * @param map<string, wild>   Bag of parameters, see @{class:PhabricatorFile}
-   *  for documentation.
+   * @param map<string, wild> $params Bag of parameters, see
+   *   @{class:PhabricatorFile} for documentation.
    * @return this
    */
   private function readPropertiesFromParameters(array $params) {
     PhutilTypeSpec::checkMap(
       $params,
       array(
         'name' => 'optional string',
         'authorPHID' => 'optional string',
         'ttl.relative' => 'optional int',
         'ttl.absolute' => 'optional int',
         'viewPolicy' => 'optional string',
         'isExplicitUpload' => 'optional bool',
         'canCDN' => 'optional bool',
         'profile' => 'optional bool',
         'format' => 'optional string|PhabricatorFileStorageFormat',
         'mime-type' => 'optional string',
         'builtin' => 'optional string',
         'storageEngines' => 'optional list<PhabricatorFileStorageEngine>',
         'chunk' => 'optional bool',
       ));
 
     $file_name = idx($params, 'name');
     $this->setName($file_name);
 
     $author_phid = idx($params, 'authorPHID');
     $this->setAuthorPHID($author_phid);
 
     $absolute_ttl = idx($params, 'ttl.absolute');
     $relative_ttl = idx($params, 'ttl.relative');
     if ($absolute_ttl !== null && $relative_ttl !== null) {
       throw new Exception(
         pht(
           'Specify an absolute TTL or a relative TTL, but not both.'));
     } else if ($absolute_ttl !== null) {
       if ($absolute_ttl < PhabricatorTime::getNow()) {
         throw new Exception(
           pht(
             'Absolute TTL must be in the present or future, but TTL "%s" '.
             'is in the past.',
             $absolute_ttl));
       }
 
       $this->setTtl($absolute_ttl);
     } else if ($relative_ttl !== null) {
       if ($relative_ttl < 0) {
         throw new Exception(
           pht(
             'Relative TTL must be zero or more seconds, but "%s" is '.
             'negative.',
             $relative_ttl));
       }
 
       $max_relative = phutil_units('365 days in seconds');
       if ($relative_ttl > $max_relative) {
         throw new Exception(
           pht(
             'Relative TTL must not be more than "%s" seconds, but TTL '.
             '"%s" was specified.',
             $max_relative,
             $relative_ttl));
       }
 
       $absolute_ttl = PhabricatorTime::getNow() + $relative_ttl;
 
       $this->setTtl($absolute_ttl);
     }
 
     $view_policy = idx($params, 'viewPolicy');
     if ($view_policy) {
       $this->setViewPolicy($params['viewPolicy']);
     }
 
     $is_explicit = (idx($params, 'isExplicitUpload') ? 1 : 0);
     $this->setIsExplicitUpload($is_explicit);
 
     $can_cdn = idx($params, 'canCDN');
     if ($can_cdn) {
       $this->setCanCDN(true);
     }
 
     $builtin = idx($params, 'builtin');
     if ($builtin) {
       $this->setBuiltinName($builtin);
       $this->setBuiltinKey($builtin);
     }
 
     $profile = idx($params, 'profile');
     if ($profile) {
       $this->setIsProfileImage(true);
     }
 
     $mime_type = idx($params, 'mime-type');
     if ($mime_type) {
       $this->setMimeType($mime_type);
     }
 
     $is_chunk = idx($params, 'chunk');
     if ($is_chunk) {
       $this->setIsChunk(true);
     }
 
     return $this;
   }
 
   public function getRedirectResponse() {
     $uri = $this->getBestURI();
 
     // TODO: This is a bit iffy. Sometimes, getBestURI() returns a CDN URI
     // (if the file is a viewable image) and sometimes a local URI (if not).
     // For now, just detect which one we got and configure the response
     // appropriately. In the long run, if this endpoint is served from a CDN
     // domain, we can't issue a local redirect to an info URI (which is not
     // present on the CDN domain). We probably never actually issue local
     // redirects here anyway, since we only ever transform viewable images
     // right now.
 
     $is_external = strlen(id(new PhutilURI($uri))->getDomain());
 
     return id(new AphrontRedirectResponse())
       ->setIsExternal($is_external)
       ->setURI($uri);
   }
 
   public function newDownloadResponse() {
     // We're cheating a little bit here and relying on the fact that
     // getDownloadURI() always returns a fully qualified URI with a complete
     // domain.
     return id(new AphrontRedirectResponse())
       ->setIsExternal(true)
       ->setCloseDialogBeforeRedirect(true)
       ->setURI($this->getDownloadURI());
   }
 
   public function attachTransforms(array $map) {
     $this->transforms = $map;
     return $this;
   }
 
   public function getTransform($key) {
     return $this->assertAttachedKey($this->transforms, $key);
   }
 
   public function newStorageFormat() {
     $key = $this->getStorageFormat();
     $template = PhabricatorFileStorageFormat::requireFormat($key);
 
     $format = id(clone $template)
       ->setFile($this);
 
     return $format;
   }
 
 
 /* -(  PhabricatorApplicationTransactionInterface  )------------------------- */
 
 
   public function getApplicationTransactionEditor() {
     return new PhabricatorFileEditor();
   }
 
   public function getApplicationTransactionTemplate() {
     return new PhabricatorFileTransaction();
   }
 
 
 /* -(  PhabricatorPolicyInterface Implementation  )-------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         if ($this->isBuiltin()) {
           return PhabricatorPolicies::getMostOpenPolicy();
         }
         if ($this->getIsProfileImage()) {
           return PhabricatorPolicies::getMostOpenPolicy();
         }
         return $this->getViewPolicy();
       case PhabricatorPolicyCapability::CAN_EDIT:
         return PhabricatorPolicies::POLICY_NOONE;
     }
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     $viewer_phid = $viewer->getPHID();
     if ($viewer_phid) {
       if ($this->getAuthorPHID() == $viewer_phid) {
         return true;
       }
     }
 
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         // If you can see the file this file is a transform of, you can see
         // this file.
         if ($this->getOriginalFile()) {
           return true;
         }
 
         // If you can see any object this file is attached to, you can see
         // the file.
         return (count($this->getObjects()) > 0);
     }
 
     return false;
   }
 
   public function describeAutomaticCapability($capability) {
     $out = array();
     $out[] = pht('The user who uploaded a file can always view and edit it.');
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         $out[] = pht(
           'Files attached to objects are visible to users who can view '.
           'those objects.');
         $out[] = pht(
           'Thumbnails are visible only to users who can view the original '.
           'file.');
         break;
     }
 
     return $out;
   }
 
 
 /* -(  PhabricatorSubscribableInterface Implementation  )-------------------- */
 
 
   public function isAutomaticallySubscribed($phid) {
     return ($this->authorPHID == $phid);
   }
 
 
 /* -(  PhabricatorTokenReceiverInterface  )---------------------------------- */
 
 
   public function getUsersToNotifyOfTokenGiven() {
     return array(
       $this->getAuthorPHID(),
     );
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
 
     $this->openTransaction();
 
       $attachments = id(new PhabricatorFileAttachment())->loadAllWhere(
         'filePHID = %s',
         $this->getPHID());
       foreach ($attachments as $attachment) {
         $attachment->delete();
       }
 
       $this->delete();
     $this->saveTransaction();
   }
 
 
 /* -(  PhabricatorConduitResultInterface  )---------------------------------- */
 
 
   public function getFieldSpecificationsForConduit() {
     return array(
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('name')
         ->setType('string')
         ->setDescription(pht('The name of the file.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('uri')
         ->setType('uri')
         ->setDescription(pht('View URI for the file.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('dataURI')
         ->setType('uri')
         ->setDescription(pht('Download URI for the file data.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('size')
         ->setType('int')
         ->setDescription(pht('File size, in bytes.')),
     );
   }
 
   public function getFieldValuesForConduit() {
     return array(
       'name' => $this->getName(),
       'uri' => PhabricatorEnv::getURI($this->getURI()),
       'dataURI' => $this->getCDNURI('data'),
       'size' => (int)$this->getByteSize(),
       'alt' => array(
         'custom' => $this->getCustomAltText(),
         'default' => $this->getDefaultAltText(),
       ),
     );
   }
 
   public function getConduitSearchAttachments() {
     return array();
   }
 
 /* -(  PhabricatorNgramInterface  )------------------------------------------ */
 
 
   public function newNgrams() {
     return array(
       id(new PhabricatorFileNameNgrams())
         ->setValue($this->getName()),
     );
   }
 
 }
diff --git a/src/applications/files/transform/PhabricatorFileImageTransform.php b/src/applications/files/transform/PhabricatorFileImageTransform.php
index 98ffcdd706..a06d00916b 100644
--- a/src/applications/files/transform/PhabricatorFileImageTransform.php
+++ b/src/applications/files/transform/PhabricatorFileImageTransform.php
@@ -1,386 +1,386 @@
 <?php
 
 abstract class PhabricatorFileImageTransform extends PhabricatorFileTransform {
 
   private $file;
   private $data;
   private $image;
   private $imageX;
   private $imageY;
 
   /**
    * Get an estimate of the transformed dimensions of a file.
    *
-   * @param PhabricatorFile File to transform.
+   * @param PhabricatorFile $file File to transform.
    * @return list<int, int>|null Width and height, if available.
    */
   public function getTransformedDimensions(PhabricatorFile $file) {
     return null;
   }
 
   public function canApplyTransform(PhabricatorFile $file) {
     if (!$file->isViewableImage()) {
       return false;
     }
 
     if (!$file->isTransformableImage()) {
       return false;
     }
 
     return true;
   }
 
   protected function willTransformFile(PhabricatorFile $file) {
     $this->file = $file;
     $this->data = null;
     $this->image = null;
     $this->imageX = null;
     $this->imageY = null;
   }
 
   protected function getFileProperties() {
     return array();
   }
 
   protected function applyCropAndScale(
     $dst_w, $dst_h,
     $src_x, $src_y,
     $src_w, $src_h,
     $use_w, $use_h,
     $scale_up) {
 
     // Figure out the effective destination width, height, and offsets.
     $cpy_w = min($dst_w, $use_w);
     $cpy_h = min($dst_h, $use_h);
 
     // If we aren't scaling up, and are copying a very small source image,
     // we're just going to center it in the destination image.
     if (!$scale_up) {
       $cpy_w = min($cpy_w, $src_w);
       $cpy_h = min($cpy_h, $src_h);
     }
 
     $off_x = ($dst_w - $cpy_w) / 2;
     $off_y = ($dst_h - $cpy_h) / 2;
 
     if ($this->shouldUseImagemagick()) {
       $argv = array();
       $argv[] = '-coalesce';
       $argv[] = '-shave';
       $argv[] = $src_x.'x'.$src_y;
       $argv[] = '-resize';
 
       if ($scale_up) {
         $argv[] = $dst_w.'x'.$dst_h;
       } else {
         $argv[] = $dst_w.'x'.$dst_h.'>';
       }
 
       $argv[] = '-bordercolor';
       $argv[] = 'rgba(255, 255, 255, 0)';
       $argv[] = '-border';
       $argv[] = $off_x.'x'.$off_y;
 
       return $this->applyImagemagick($argv);
     }
 
     $src = $this->getImage();
     $dst = $this->newEmptyImage($dst_w, $dst_h);
 
     $trap = new PhutilErrorTrap();
     $ok = @imagecopyresampled(
       $dst,
       $src,
       $off_x, $off_y,
       $src_x, $src_y,
       $cpy_w, $cpy_h,
       $src_w, $src_h);
     $errors = $trap->getErrorsAsString();
     $trap->destroy();
 
     if ($ok === false) {
       throw new Exception(
         pht(
           'Failed to imagecopyresampled() image: %s',
           $errors));
     }
 
     $data = PhabricatorImageTransformer::saveImageDataInAnyFormat(
       $dst,
       $this->file->getMimeType());
 
     return $this->newFileFromData($data);
   }
 
   protected function applyImagemagick(array $argv) {
     $tmp = new TempFile();
     Filesystem::writeFile($tmp, $this->getData());
 
     $out = new TempFile();
 
     $future = new ExecFuture('convert %s %Ls %s', $tmp, $argv, $out);
     // Don't spend more than 60 seconds resizing; just fail if it takes longer
     // than that.
     $future->setTimeout(60)->resolvex();
 
     $data = Filesystem::readFile($out);
 
     return $this->newFileFromData($data);
   }
 
 
   /**
    * Create a new @{class:PhabricatorFile} from raw data.
    *
-   * @param string Raw file data.
+   * @param string $data Raw file data.
    */
   protected function newFileFromData($data) {
     if ($this->file) {
       $name = $this->file->getName();
       $inherit_properties = array(
         'viewPolicy' => $this->file->getViewPolicy(),
       );
     } else {
       $name = 'default.png';
       $inherit_properties = array();
     }
 
     $defaults = array(
       'canCDN' => true,
       'name' => $this->getTransformKey().'-'.$name,
     );
 
     $properties = $this->getFileProperties() + $inherit_properties + $defaults;
 
     return PhabricatorFile::newFromFileData($data, $properties);
   }
 
 
   /**
    * Create a new image filled with transparent pixels.
    *
-   * @param int Desired image width.
-   * @param int Desired image height.
+   * @param int $w Desired image width.
+   * @param int $h Desired image height.
    * @return resource New image resource.
    */
   protected function newEmptyImage($w, $h) {
     $w = (int)$w;
     $h = (int)$h;
 
     if (($w <= 0) || ($h <= 0)) {
       throw new Exception(
         pht('Can not create an image with nonpositive dimensions.'));
     }
 
     $trap = new PhutilErrorTrap();
     $img = @imagecreatetruecolor($w, $h);
     $errors = $trap->getErrorsAsString();
     $trap->destroy();
     if ($img === false) {
       throw new Exception(
         pht(
           'Unable to imagecreatetruecolor() a new empty image: %s',
           $errors));
     }
 
     $trap = new PhutilErrorTrap();
     $ok = @imagesavealpha($img, true);
     $errors = $trap->getErrorsAsString();
     $trap->destroy();
     if ($ok === false) {
       throw new Exception(
         pht(
           'Unable to imagesavealpha() a new empty image: %s',
           $errors));
     }
 
     $trap = new PhutilErrorTrap();
     $color = @imagecolorallocatealpha($img, 255, 255, 255, 127);
     $errors = $trap->getErrorsAsString();
     $trap->destroy();
     if ($color === false) {
       throw new Exception(
         pht(
           'Unable to imagecolorallocatealpha() a new empty image: %s',
           $errors));
     }
 
     $trap = new PhutilErrorTrap();
     $ok = @imagefill($img, 0, 0, $color);
     $errors = $trap->getErrorsAsString();
     $trap->destroy();
     if ($ok === false) {
       throw new Exception(
         pht(
           'Unable to imagefill() a new empty image: %s',
           $errors));
     }
 
     return $img;
   }
 
 
   /**
    * Get the pixel dimensions of the image being transformed.
    *
    * @return list<int, int> Width and height of the image.
    */
   protected function getImageDimensions() {
     if ($this->imageX === null) {
       $image = $this->getImage();
 
       $trap = new PhutilErrorTrap();
       $x = @imagesx($image);
       $y = @imagesy($image);
       $errors = $trap->getErrorsAsString();
       $trap->destroy();
 
       if (($x === false) || ($y === false) || ($x <= 0) || ($y <= 0)) {
         throw new Exception(
           pht(
             'Unable to determine image dimensions with '.
             'imagesx()/imagesy(): %s',
             $errors));
       }
 
       $this->imageX = $x;
       $this->imageY = $y;
     }
 
     return array($this->imageX, $this->imageY);
   }
 
 
   /**
    * Get the raw file data for the image being transformed.
    *
    * @return string Raw file data.
    */
   protected function getData() {
     if ($this->data !== null) {
       return $this->data;
     }
 
     $file = $this->file;
 
     $max_size = (1024 * 1024 * 16);
     $img_size = $file->getByteSize();
     if ($img_size > $max_size) {
       throw new Exception(
         pht(
           'This image is too large to transform. The transform limit is %s '.
           'bytes, but the image size is %s bytes.',
           new PhutilNumber($max_size),
           new PhutilNumber($img_size)));
     }
 
     $data = $file->loadFileData();
     $this->data = $data;
     return $this->data;
   }
 
 
   /**
    * Get the GD image resource for the image being transformed.
    *
    * @return resource GD image resource.
    */
   protected function getImage() {
     if ($this->image !== null) {
       return $this->image;
     }
 
     if (!function_exists('imagecreatefromstring')) {
       throw new Exception(
         pht(
           'Unable to transform image: the imagecreatefromstring() function '.
           'is not available. Install or enable the "gd" extension for PHP.'));
     }
 
     $data = $this->getData();
     $data = (string)$data;
 
     // First, we're going to write the file to disk and use getimagesize()
     // to determine its dimensions without actually loading the pixel data
     // into memory. For very large images, we'll bail out.
 
     // In particular, this defuses a resource exhaustion attack where the
     // attacker uploads a 40,000 x 40,000 pixel PNGs of solid white. These
     // kinds of files compress extremely well, but require a huge amount
     // of memory and CPU to process.
 
     $tmp = new TempFile();
     Filesystem::writeFile($tmp, $data);
     $tmp_path = (string)$tmp;
 
     $trap = new PhutilErrorTrap();
     $info = @getimagesize($tmp_path);
     $errors = $trap->getErrorsAsString();
     $trap->destroy();
 
     unset($tmp);
 
     if ($info === false) {
       throw new Exception(
         pht(
           'Unable to get image information with getimagesize(): %s',
           $errors));
     }
 
     list($width, $height) = $info;
     if (($width <= 0) || ($height <= 0)) {
       throw new Exception(
         pht(
           'Unable to determine image width and height with getimagesize().'));
     }
 
     $max_pixels = (4096 * 4096);
     $img_pixels = ($width * $height);
 
     if ($img_pixels > $max_pixels) {
       throw new Exception(
         pht(
           'This image (with dimensions %spx x %spx) is too large to '.
           'transform. The image has %s pixels, but transforms are limited '.
           'to images with %s or fewer pixels.',
           new PhutilNumber($width),
           new PhutilNumber($height),
           new PhutilNumber($img_pixels),
           new PhutilNumber($max_pixels)));
     }
 
     $trap = new PhutilErrorTrap();
     $image = @imagecreatefromstring($data);
     $errors = $trap->getErrorsAsString();
     $trap->destroy();
 
     if ($image === false) {
       throw new Exception(
         pht(
           'Unable to load image data with imagecreatefromstring(): %s',
           $errors));
     }
 
     $this->image = $image;
     return $this->image;
   }
 
   private function shouldUseImagemagick() {
     if (!PhabricatorEnv::getEnvConfig('files.enable-imagemagick')) {
       return false;
     }
 
     if ($this->file->getMimeType() != 'image/gif') {
       return false;
     }
 
     // Don't try to preserve the animation in huge GIFs.
     list($x, $y) = $this->getImageDimensions();
     if (($x * $y) > (512 * 512)) {
       return false;
     }
 
     return true;
   }
 
 }
diff --git a/src/applications/harbormaster/constants/HarbormasterBuildStatus.php b/src/applications/harbormaster/constants/HarbormasterBuildStatus.php
index 009758078f..9fe61b5f91 100644
--- a/src/applications/harbormaster/constants/HarbormasterBuildStatus.php
+++ b/src/applications/harbormaster/constants/HarbormasterBuildStatus.php
@@ -1,289 +1,289 @@
 <?php
 
 final class HarbormasterBuildStatus extends Phobject {
 
   const STATUS_INACTIVE = 'inactive';
   const STATUS_PENDING = 'pending';
   const STATUS_BUILDING = 'building';
   const STATUS_PASSED = 'passed';
   const STATUS_FAILED = 'failed';
   const STATUS_ABORTED = 'aborted';
   const STATUS_ERROR = 'error';
   const STATUS_PAUSED = 'paused';
   const STATUS_DEADLOCKED = 'deadlocked';
 
   const PENDING_PAUSING = 'x-pausing';
   const PENDING_RESUMING = 'x-resuming';
   const PENDING_RESTARTING = 'x-restarting';
   const PENDING_ABORTING = 'x-aborting';
 
   private $key;
   private $properties;
 
   public function __construct($key, array $properties) {
     $this->key = $key;
     $this->properties = $properties;
   }
 
   public static function newBuildStatusObject($status) {
     $spec = self::getBuildStatusSpec($status);
     return new self($status, $spec);
   }
 
   private function getProperty($key) {
     if (!array_key_exists($key, $this->properties)) {
       throw new Exception(
         pht(
           'Attempting to access unknown build status property ("%s").',
           $key));
     }
 
     return $this->properties[$key];
   }
 
   public function isBuilding() {
     return $this->getProperty('isBuilding');
   }
 
   public function isPaused() {
     return ($this->key === self::STATUS_PAUSED);
   }
 
   public function isComplete() {
     return $this->getProperty('isComplete');
   }
 
   public function isPassed() {
     return ($this->key === self::STATUS_PASSED);
   }
 
   public function isFailed() {
     return ($this->key === self::STATUS_FAILED);
   }
 
   public function isAborting() {
     return ($this->key === self::PENDING_ABORTING);
   }
 
   public function isRestarting() {
     return ($this->key === self::PENDING_RESTARTING);
   }
 
   public function isResuming() {
     return ($this->key === self::PENDING_RESUMING);
   }
 
   public function isPausing() {
     return ($this->key === self::PENDING_PAUSING);
   }
 
   public function isPending() {
     return ($this->key === self::STATUS_PENDING);
   }
 
   public function getIconIcon() {
     return $this->getProperty('icon');
   }
 
   public function getIconColor() {
     return $this->getProperty('color');
   }
 
   public function getName() {
     return $this->getProperty('name');
   }
 
   /**
    * Get a human readable name for a build status constant.
    *
-   * @param  const  Build status constant.
+   * @param  const $status Build status constant.
    * @return string Human-readable name.
    */
   public static function getBuildStatusName($status) {
     $spec = self::getBuildStatusSpec($status);
     return $spec['name'];
   }
 
   public static function getBuildStatusMap() {
     $specs = self::getBuildStatusSpecMap();
     return ipull($specs, 'name');
   }
 
   public static function getBuildStatusIcon($status) {
     $spec = self::getBuildStatusSpec($status);
     return $spec['icon'];
   }
 
   public static function getBuildStatusColor($status) {
     $spec = self::getBuildStatusSpec($status);
     return $spec['color'];
   }
 
   public static function getBuildStatusANSIColor($status) {
     $spec = self::getBuildStatusSpec($status);
     return $spec['color.ansi'];
   }
 
   public static function getWaitingStatusConstants() {
     return array(
       self::STATUS_INACTIVE,
       self::STATUS_PENDING,
     );
   }
 
   public static function getActiveStatusConstants() {
     return array(
       self::STATUS_BUILDING,
       self::STATUS_PAUSED,
     );
   }
 
   public static function getIncompleteStatusConstants() {
     $map = self::getBuildStatusSpecMap();
 
     $constants = array();
     foreach ($map as $constant => $spec) {
       if (!$spec['isComplete']) {
         $constants[] = $constant;
       }
     }
 
     return $constants;
   }
 
   public static function getCompletedStatusConstants() {
     return array(
       self::STATUS_PASSED,
       self::STATUS_FAILED,
       self::STATUS_ABORTED,
       self::STATUS_ERROR,
       self::STATUS_DEADLOCKED,
     );
   }
 
   private static function getBuildStatusSpecMap() {
     return array(
       self::STATUS_INACTIVE => array(
         'name' => pht('Inactive'),
         'icon' => 'fa-circle-o',
         'color' => 'dark',
         'color.ansi' => 'yellow',
         'isBuilding' => false,
         'isComplete' => false,
       ),
       self::STATUS_PENDING => array(
         'name' => pht('Pending'),
         'icon' => 'fa-circle-o',
         'color' => 'blue',
         'color.ansi' => 'yellow',
         'isBuilding' => true,
         'isComplete' => false,
       ),
       self::STATUS_BUILDING => array(
         'name' => pht('Building'),
         'icon' => 'fa-chevron-circle-right',
         'color' => 'blue',
         'color.ansi' => 'yellow',
         'isBuilding' => true,
         'isComplete' => false,
       ),
       self::STATUS_PASSED => array(
         'name' => pht('Passed'),
         'icon' => 'fa-check-circle',
         'color' => 'green',
         'color.ansi' => 'green',
         'isBuilding' => false,
         'isComplete' => true,
       ),
       self::STATUS_FAILED => array(
         'name' => pht('Failed'),
         'icon' => 'fa-times-circle',
         'color' => 'red',
         'color.ansi' => 'red',
         'isBuilding' => false,
         'isComplete' => true,
       ),
       self::STATUS_ABORTED => array(
         'name' => pht('Aborted'),
         'icon' => 'fa-minus-circle',
         'color' => 'red',
         'color.ansi' => 'red',
         'isBuilding' => false,
         'isComplete' => true,
       ),
       self::STATUS_ERROR => array(
         'name' => pht('Unexpected Error'),
         'icon' => 'fa-minus-circle',
         'color' => 'red',
         'color.ansi' => 'red',
         'isBuilding' => false,
         'isComplete' => true,
       ),
       self::STATUS_PAUSED => array(
         'name' => pht('Paused'),
         'icon' => 'fa-pause',
         'color' => 'yellow',
         'color.ansi' => 'yellow',
         'isBuilding' => false,
         'isComplete' => false,
       ),
       self::STATUS_DEADLOCKED => array(
         'name' => pht('Deadlocked'),
         'icon' => 'fa-exclamation-circle',
         'color' => 'red',
         'color.ansi' => 'red',
         'isBuilding' => false,
         'isComplete' => true,
       ),
       self::PENDING_PAUSING => array(
         'name' => pht('Pausing'),
         'icon' => 'fa-exclamation-triangle',
         'color' => 'red',
         'color.ansi' => 'red',
         'isBuilding' => false,
         'isComplete' => false,
       ),
       self::PENDING_RESUMING => array(
         'name' => pht('Resuming'),
         'icon' => 'fa-exclamation-triangle',
         'color' => 'red',
         'color.ansi' => 'red',
         'isBuilding' => false,
         'isComplete' => false,
       ),
       self::PENDING_RESTARTING => array(
         'name' => pht('Restarting'),
         'icon' => 'fa-exclamation-triangle',
         'color' => 'red',
         'color.ansi' => 'red',
         'isBuilding' => false,
         'isComplete' => false,
       ),
       self::PENDING_ABORTING => array(
         'name' => pht('Aborting'),
         'icon' => 'fa-exclamation-triangle',
         'color' => 'red',
         'color.ansi' => 'red',
         'isBuilding' => false,
         'isComplete' => false,
       ),
     );
   }
 
   private static function getBuildStatusSpec($status) {
     $map = self::getBuildStatusSpecMap();
     if (isset($map[$status])) {
       return $map[$status];
     }
 
     return array(
       'name' => pht('Unknown ("%s")', $status),
       'icon' => 'fa-question-circle',
       'color' => 'bluegrey',
       'color.ansi' => 'magenta',
       'isBuilding' => false,
       'isComplete' => false,
     );
   }
 
 }
diff --git a/src/applications/harbormaster/engine/HarbormasterBuildEngine.php b/src/applications/harbormaster/engine/HarbormasterBuildEngine.php
index fd72e31a0e..266fed3804 100644
--- a/src/applications/harbormaster/engine/HarbormasterBuildEngine.php
+++ b/src/applications/harbormaster/engine/HarbormasterBuildEngine.php
@@ -1,617 +1,618 @@
 <?php
 
 /**
  * Moves a build forward by queuing build tasks, canceling or restarting the
  * build, or failing it in response to task failures.
  */
 final class HarbormasterBuildEngine extends Phobject {
 
   private $build;
   private $viewer;
   private $newBuildTargets = array();
   private $artifactReleaseQueue = array();
   private $forceBuildableUpdate;
 
   public function setForceBuildableUpdate($force_buildable_update) {
     $this->forceBuildableUpdate = $force_buildable_update;
     return $this;
   }
 
   public function shouldForceBuildableUpdate() {
     return $this->forceBuildableUpdate;
   }
 
   public function queueNewBuildTarget(HarbormasterBuildTarget $target) {
     $this->newBuildTargets[] = $target;
     return $this;
   }
 
   public function getNewBuildTargets() {
     return $this->newBuildTargets;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   public function setBuild(HarbormasterBuild $build) {
     $this->build = $build;
     return $this;
   }
 
   public function getBuild() {
     return $this->build;
   }
 
   public function continueBuild() {
     $viewer = $this->getViewer();
     $build = $this->getBuild();
 
     $lock_key = 'harbormaster.build:'.$build->getID();
     $lock = PhabricatorGlobalLock::newLock($lock_key)->lock(15);
 
     $build->reload();
     $old_status = $build->getBuildStatus();
 
     try {
       $this->updateBuild($build);
     } catch (Exception $ex) {
       // If any exception is raised, the build is marked as a failure and the
       // exception is re-thrown (this ensures we don't leave builds in an
       // inconsistent state).
       $build->setBuildStatus(HarbormasterBuildStatus::STATUS_ERROR);
       $build->save();
 
       $lock->unlock();
 
       $build->releaseAllArtifacts($viewer);
 
       throw $ex;
     }
 
     $lock->unlock();
 
     // NOTE: We queue new targets after releasing the lock so that in-process
     // execution via `bin/harbormaster` does not reenter the locked region.
     foreach ($this->getNewBuildTargets() as $target) {
       $task = PhabricatorWorker::scheduleTask(
         'HarbormasterTargetWorker',
         array(
           'targetID' => $target->getID(),
         ),
         array(
           'objectPHID' => $target->getPHID(),
         ));
     }
 
     // If the build changed status, we might need to update the overall status
     // on the buildable.
     $new_status = $build->getBuildStatus();
     if ($new_status != $old_status || $this->shouldForceBuildableUpdate()) {
       $this->updateBuildable($build->getBuildable());
     }
 
     $this->releaseQueuedArtifacts();
 
     // If we are no longer building for any reason, release all artifacts.
     if (!$build->isBuilding()) {
       $build->releaseAllArtifacts($viewer);
     }
   }
 
   private function updateBuild(HarbormasterBuild $build) {
     $viewer = $this->getViewer();
 
     $content_source = PhabricatorContentSource::newForSource(
       PhabricatorDaemonContentSource::SOURCECONST);
 
     $acting_phid = $viewer->getPHID();
     if (!$acting_phid) {
       $acting_phid = id(new PhabricatorHarbormasterApplication())->getPHID();
     }
 
     $editor = $build->getApplicationTransactionEditor()
       ->setActor($viewer)
       ->setActingAsPHID($acting_phid)
       ->setContentSource($content_source)
       ->setContinueOnNoEffect(true)
       ->setContinueOnMissingFields(true);
 
     $xactions = array();
 
     $messages = $build->getUnprocessedMessagesForApply();
     foreach ($messages as $message) {
       $message_type = $message->getType();
 
       $message_xaction =
         HarbormasterBuildMessageTransaction::getTransactionTypeForMessageType(
           $message_type);
 
       if (!$message_xaction) {
         continue;
       }
 
       $xactions[] = $build->getApplicationTransactionTemplate()
         ->setAuthorPHID($message->getAuthorPHID())
         ->setTransactionType($message_xaction)
         ->setNewValue($message_type);
     }
 
     if (!$xactions) {
       if ($build->isPending()) {
         // TODO: This should be a transaction.
 
         $build->restartBuild($viewer);
         $build->setBuildStatus(HarbormasterBuildStatus::STATUS_BUILDING);
         $build->save();
       }
     }
 
     if ($xactions) {
       $editor->applyTransactions($build, $xactions);
       $build->markUnprocessedMessagesAsProcessed();
     }
 
     if ($build->getBuildStatus() == HarbormasterBuildStatus::STATUS_BUILDING) {
       $this->updateBuildSteps($build);
     }
   }
 
   private function updateBuildSteps(HarbormasterBuild $build) {
     $all_targets = id(new HarbormasterBuildTargetQuery())
       ->setViewer($this->getViewer())
       ->withBuildPHIDs(array($build->getPHID()))
       ->withBuildGenerations(array($build->getBuildGeneration()))
       ->execute();
 
     $this->updateWaitingTargets($all_targets);
 
     $targets = mgroup($all_targets, 'getBuildStepPHID');
 
     $steps = id(new HarbormasterBuildStepQuery())
       ->setViewer($this->getViewer())
       ->withBuildPlanPHIDs(array($build->getBuildPlan()->getPHID()))
       ->execute();
     $steps = mpull($steps, null, 'getPHID');
 
     // Identify steps which are in various states.
 
     $queued = array();
     $underway = array();
     $waiting = array();
     $complete = array();
     $failed = array();
     foreach ($steps as $step) {
       $step_targets = idx($targets, $step->getPHID(), array());
 
       if ($step_targets) {
         $is_queued = false;
 
         $is_underway = false;
         foreach ($step_targets as $target) {
           if ($target->isUnderway()) {
             $is_underway = true;
             break;
           }
         }
 
         $is_waiting = false;
         foreach ($step_targets as $target) {
           if ($target->isWaiting()) {
             $is_waiting = true;
             break;
           }
         }
 
         $is_complete = true;
         foreach ($step_targets as $target) {
           if (!$target->isComplete()) {
             $is_complete = false;
             break;
           }
         }
 
         $is_failed = false;
         foreach ($step_targets as $target) {
           if ($target->isFailed()) {
             $is_failed = true;
             break;
           }
         }
       } else {
         $is_queued = true;
         $is_underway = false;
         $is_waiting = false;
         $is_complete = false;
         $is_failed = false;
       }
 
       if ($is_queued) {
         $queued[$step->getPHID()] = true;
       }
 
       if ($is_underway) {
         $underway[$step->getPHID()] = true;
       }
 
       if ($is_waiting) {
         $waiting[$step->getPHID()] = true;
       }
 
       if ($is_complete) {
         $complete[$step->getPHID()] = true;
       }
 
       if ($is_failed) {
         $failed[$step->getPHID()] = true;
       }
     }
 
     // If any step failed, fail the whole build, then bail.
     if (count($failed)) {
       $build->setBuildStatus(HarbormasterBuildStatus::STATUS_FAILED);
       $build->save();
       return;
     }
 
     // If every step is complete, we're done with this build. Mark it passed
     // and bail.
     if (count($complete) == count($steps)) {
       $build->setBuildStatus(HarbormasterBuildStatus::STATUS_PASSED);
       $build->save();
       return;
     }
 
     // Release any artifacts which are not inputs to any remaining build
     // step. We're done with these, so something else is free to use them.
     $ongoing_phids = array_keys($queued + $waiting + $underway);
     $ongoing_steps = array_select_keys($steps, $ongoing_phids);
     $this->releaseUnusedArtifacts($all_targets, $ongoing_steps);
 
     // Identify all the steps which are ready to run (because all their
     // dependencies are complete).
 
     $runnable = array();
     foreach ($steps as $step) {
       $dependencies = $step->getStepImplementation()->getDependencies($step);
 
       if (isset($queued[$step->getPHID()])) {
         $can_run = true;
         foreach ($dependencies as $dependency) {
           if (empty($complete[$dependency])) {
             $can_run = false;
             break;
           }
         }
 
         if ($can_run) {
           $runnable[] = $step;
         }
       }
     }
 
     if (!$runnable && !$waiting && !$underway) {
       // This means the build is deadlocked, and the user has configured
       // circular dependencies.
       $build->setBuildStatus(HarbormasterBuildStatus::STATUS_DEADLOCKED);
       $build->save();
       return;
     }
 
     foreach ($runnable as $runnable_step) {
       $target = HarbormasterBuildTarget::initializeNewBuildTarget(
         $build,
         $runnable_step,
         $build->retrieveVariablesFromBuild());
       $target->save();
 
       $this->queueNewBuildTarget($target);
     }
   }
 
 
   /**
    * Release any artifacts which aren't used by any running or waiting steps.
    *
    * This releases artifacts as soon as they're no longer used. This can be
    * particularly relevant when a build uses multiple hosts since it returns
    * hosts to the pool more quickly.
    *
-   * @param list<HarbormasterBuildTarget> Targets in the build.
-   * @param list<HarbormasterBuildStep> List of running and waiting steps.
+   * @param list<HarbormasterBuildTarget> $targets Targets in the build.
+   * @param list<HarbormasterBuildStep>   $steps List of running and waiting
+   *                                      steps.
    * @return void
    */
   private function releaseUnusedArtifacts(array $targets, array $steps) {
     assert_instances_of($targets, 'HarbormasterBuildTarget');
     assert_instances_of($steps, 'HarbormasterBuildStep');
 
     if (!$targets || !$steps) {
       return;
     }
 
     $target_phids = mpull($targets, 'getPHID');
 
     $artifacts = id(new HarbormasterBuildArtifactQuery())
       ->setViewer($this->getViewer())
       ->withBuildTargetPHIDs($target_phids)
       ->withIsReleased(false)
       ->execute();
     if (!$artifacts) {
       return;
     }
 
     // Collect all the artifacts that remaining build steps accept as inputs.
     $must_keep = array();
     foreach ($steps as $step) {
       $inputs = $step->getStepImplementation()->getArtifactInputs();
       foreach ($inputs as $input) {
         $artifact_key = $input['key'];
         $must_keep[$artifact_key] = true;
       }
     }
 
     // Queue unreleased artifacts which no remaining step uses for immediate
     // release.
     foreach ($artifacts as $artifact) {
       $key = $artifact->getArtifactKey();
       if (isset($must_keep[$key])) {
         continue;
       }
 
       $this->artifactReleaseQueue[] = $artifact;
     }
   }
 
 
   /**
    * Process messages which were sent to these targets, kicking applicable
    * targets out of "Waiting" and into either "Passed" or "Failed".
    *
-   * @param list<HarbormasterBuildTarget> List of targets to process.
+   * @param list<HarbormasterBuildTarget> $targets List of targets to process.
    * @return void
    */
   private function updateWaitingTargets(array $targets) {
     assert_instances_of($targets, 'HarbormasterBuildTarget');
 
     // We only care about messages for targets which are actually in a waiting
     // state.
     $waiting_targets = array();
     foreach ($targets as $target) {
       if ($target->isWaiting()) {
         $waiting_targets[$target->getPHID()] = $target;
       }
     }
 
     if (!$waiting_targets) {
       return;
     }
 
     $messages = id(new HarbormasterBuildMessageQuery())
       ->setViewer($this->getViewer())
       ->withReceiverPHIDs(array_keys($waiting_targets))
       ->withConsumed(false)
       ->execute();
 
     foreach ($messages as $message) {
       $target = $waiting_targets[$message->getReceiverPHID()];
 
       switch ($message->getType()) {
         case HarbormasterMessageType::MESSAGE_PASS:
           $new_status = HarbormasterBuildTarget::STATUS_PASSED;
           break;
         case HarbormasterMessageType::MESSAGE_FAIL:
           $new_status = HarbormasterBuildTarget::STATUS_FAILED;
           break;
         case HarbormasterMessageType::MESSAGE_WORK:
         default:
           $new_status = null;
           break;
       }
 
       if ($new_status !== null) {
         $message->setIsConsumed(true);
         $message->save();
 
         $target->setTargetStatus($new_status);
 
         if ($target->isComplete()) {
           $target->setDateCompleted(PhabricatorTime::getNow());
         }
 
         $target->save();
       }
     }
   }
 
 
   /**
    * Update the overall status of the buildable this build is attached to.
    *
    * After a build changes state (for example, passes or fails) it may affect
    * the overall state of the associated buildable. Compute the new aggregate
    * state and save it on the buildable.
    *
-   * @param   HarbormasterBuild The buildable to update.
+   * @param   HarbormasterBuild $buildable The buildable to update.
    * @return  void
    */
    public function updateBuildable(HarbormasterBuildable $buildable) {
     $viewer = $this->getViewer();
 
     $lock_key = 'harbormaster.buildable:'.$buildable->getID();
     $lock = PhabricatorGlobalLock::newLock($lock_key)->lock(15);
 
     $buildable = id(new HarbormasterBuildableQuery())
       ->setViewer($viewer)
       ->withIDs(array($buildable->getID()))
       ->needBuilds(true)
       ->executeOne();
 
     $messages = id(new HarbormasterBuildMessageQuery())
       ->setViewer($viewer)
       ->withReceiverPHIDs(array($buildable->getPHID()))
       ->withConsumed(false)
       ->execute();
 
     $done_preparing = false;
     $update_container = false;
     foreach ($messages as $message) {
       switch ($message->getType()) {
         case HarbormasterMessageType::BUILDABLE_BUILD:
           $done_preparing = true;
           break;
         case HarbormasterMessageType::BUILDABLE_CONTAINER:
           $update_container = true;
           break;
         default:
           break;
       }
 
       $message
         ->setIsConsumed(true)
         ->save();
     }
 
     // If we received a "build" command, all builds are scheduled and we can
     // move out of "preparing" into "building".
     if ($done_preparing) {
       if ($buildable->isPreparing()) {
         $buildable
           ->setBuildableStatus(HarbormasterBuildableStatus::STATUS_BUILDING)
           ->save();
       }
     }
 
     // If we've been informed that the container for the buildable has
     // changed, update it.
     if ($update_container) {
       $object = id(new PhabricatorObjectQuery())
         ->setViewer($viewer)
         ->withPHIDs(array($buildable->getBuildablePHID()))
         ->executeOne();
       if ($object) {
         $buildable
           ->setContainerPHID($object->getHarbormasterContainerPHID())
           ->save();
       }
     }
 
     $old = clone $buildable;
 
     // Don't update the buildable status if we're still preparing builds: more
     // builds may still be scheduled shortly, so even if every build we know
     // about so far has passed, that doesn't mean the buildable has actually
     // passed everything it needs to.
 
     if (!$buildable->isPreparing()) {
       $behavior_key = HarbormasterBuildPlanBehavior::BEHAVIOR_BUILDABLE;
       $behavior = HarbormasterBuildPlanBehavior::getBehavior($behavior_key);
 
       $key_never = HarbormasterBuildPlanBehavior::BUILDABLE_NEVER;
       $key_building = HarbormasterBuildPlanBehavior::BUILDABLE_IF_BUILDING;
 
       $all_pass = true;
       $any_fail = false;
       foreach ($buildable->getBuilds() as $build) {
         $plan = $build->getBuildPlan();
         $option = $behavior->getPlanOption($plan);
         $option_key = $option->getKey();
 
         $is_never = ($option_key === $key_never);
         $is_building = ($option_key === $key_building);
 
         // If this build "Never" affects the buildable, ignore it.
         if ($is_never) {
           continue;
         }
 
         // If this build affects the buildable "If Building", but is already
         // complete, ignore it.
         if ($is_building && $build->isComplete()) {
           continue;
         }
 
         if (!$build->isPassed()) {
           $all_pass = false;
         }
 
         if ($build->isComplete() && !$build->isPassed()) {
           $any_fail = true;
         }
       }
 
       if ($any_fail) {
         $new_status = HarbormasterBuildableStatus::STATUS_FAILED;
       } else if ($all_pass) {
         $new_status = HarbormasterBuildableStatus::STATUS_PASSED;
       } else {
         $new_status = HarbormasterBuildableStatus::STATUS_BUILDING;
       }
 
       $did_update = ($old->getBuildableStatus() !== $new_status);
       if ($did_update) {
         $buildable->setBuildableStatus($new_status);
         $buildable->save();
       }
     }
 
     $lock->unlock();
 
     // Don't publish anything if we're still preparing builds.
     if ($buildable->isPreparing()) {
       return;
     }
 
     $this->publishBuildable($old, $buildable);
   }
 
   public function publishBuildable(
     HarbormasterBuildable $old,
     HarbormasterBuildable $new) {
 
     $viewer = $this->getViewer();
 
     // Publish the buildable. We publish buildables even if they haven't
     // changed status in Harbormaster because applications may care about
     // different things than Harbormaster does. For example, Differential
     // does not care about local lint and unit tests when deciding whether
     // a revision should move out of draft or not.
 
     // NOTE: We're publishing both automatic and manual buildables. Buildable
     // objects should generally ignore manual buildables, but it's up to them
     // to decide.
 
     $object = id(new PhabricatorObjectQuery())
       ->setViewer($viewer)
       ->withPHIDs(array($new->getBuildablePHID()))
       ->executeOne();
     if (!$object) {
       return;
     }
 
     $engine = HarbormasterBuildableEngine::newForObject($object, $viewer);
 
     $daemon_source = PhabricatorContentSource::newForSource(
       PhabricatorDaemonContentSource::SOURCECONST);
 
     $harbormaster_phid = id(new PhabricatorHarbormasterApplication())
       ->getPHID();
 
     $engine
       ->setActingAsPHID($harbormaster_phid)
       ->setContentSource($daemon_source)
       ->publishBuildable($old, $new);
   }
 
   private function releaseQueuedArtifacts() {
     foreach ($this->artifactReleaseQueue as $key => $artifact) {
       $artifact->releaseArtifact();
       unset($this->artifactReleaseQueue[$key]);
     }
   }
 
 }
diff --git a/src/applications/harbormaster/engine/HarbormasterTargetEngine.php b/src/applications/harbormaster/engine/HarbormasterTargetEngine.php
index d9efb8b9fd..62f679774d 100644
--- a/src/applications/harbormaster/engine/HarbormasterTargetEngine.php
+++ b/src/applications/harbormaster/engine/HarbormasterTargetEngine.php
@@ -1,255 +1,255 @@
 <?php
 
 final class HarbormasterTargetEngine extends Phobject {
 
   private $viewer;
   private $object;
   private $autoTargetKeys;
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   public function setObject(HarbormasterBuildableInterface $object) {
     $this->object = $object;
     return $this;
   }
 
   public function getObject() {
     return $this->object;
   }
 
   public function setAutoTargetKeys(array $auto_keys) {
     $this->autoTargetKeys = $auto_keys;
     return $this;
   }
 
   public function getAutoTargetKeys() {
     return $this->autoTargetKeys;
   }
 
   public function buildTargets() {
     $object = $this->getObject();
     $viewer = $this->getViewer();
 
     $step_map = $this->generateBuildStepMap($this->getAutoTargetKeys());
 
     $buildable = HarbormasterBuildable::createOrLoadExisting(
       $viewer,
       $object->getHarbormasterBuildablePHID(),
       $object->getHarbormasterContainerPHID());
 
     $target_map = $this->generateBuildTargetMap($buildable, $step_map);
 
     return $target_map;
   }
 
 
   /**
    * Get a map of the @{class:HarbormasterBuildStep} objects for a list of
    * autotarget keys.
    *
    * This method creates the steps if they do not yet exist.
    *
-   * @param list<string> Autotarget keys, like `"core.arc.lint"`.
+   * @param list<string> $autotargets Autotarget keys, like `"core.arc.lint"`.
    * @return map<string, object> Map of keys to step objects.
    */
   private function generateBuildStepMap(array $autotargets) {
     $viewer = $this->getViewer();
 
     $autosteps = $this->getAutosteps($autotargets);
     $autosteps = mgroup($autosteps, 'getBuildStepAutotargetPlanKey');
 
     $plans = id(new HarbormasterBuildPlanQuery())
       ->setViewer($viewer)
       ->withPlanAutoKeys(array_keys($autosteps))
       ->needBuildSteps(true)
       ->execute();
     $plans = mpull($plans, null, 'getPlanAutoKey');
 
     // NOTE: When creating the plan and steps, we save the autokeys as the
     // names. These won't actually be shown in the UI, but make the data more
     // consistent for secondary consumers like typeaheads.
 
     $step_map = array();
     foreach ($autosteps as $plan_key => $steps) {
       $plan = idx($plans, $plan_key);
       if (!$plan) {
         $plan = HarbormasterBuildPlan::initializeNewBuildPlan($viewer)
           ->setName($plan_key)
           ->setPlanAutoKey($plan_key);
       }
 
       $current = $plan->getBuildSteps();
       $current = mpull($current, null, 'getStepAutoKey');
       $new_steps = array();
 
       foreach ($steps as $step_key => $step) {
         if (isset($current[$step_key])) {
           $step_map[$step_key] = $current[$step_key];
           continue;
         }
 
         $new_step = HarbormasterBuildStep::initializeNewStep($viewer)
           ->setName($step_key)
           ->setClassName(get_class($step))
           ->setStepAutoKey($step_key);
 
         $new_steps[$step_key] = $new_step;
       }
 
       if ($new_steps) {
         $plan->openTransaction();
           if (!$plan->getPHID()) {
             $plan->save();
           }
           foreach ($new_steps as $step_key => $step) {
             $step->setBuildPlanPHID($plan->getPHID());
             $step->save();
 
             $step->attachBuildPlan($plan);
             $step_map[$step_key] = $step;
           }
         $plan->saveTransaction();
       }
     }
 
     return $step_map;
   }
 
 
   /**
    * Get all of the @{class:HarbormasterBuildStepImplementation} objects for
    * a list of autotarget keys.
    *
-   * @param list<string> Autotarget keys, like `"core.arc.lint"`.
+   * @param list<string> $autotargets Autotarget keys, like `"core.arc.lint"`.
    * @return map<string, object> Map of keys to implementations.
    */
   private function getAutosteps(array $autotargets) {
     $all_steps = HarbormasterBuildStepImplementation::getImplementations();
     $all_steps = mpull($all_steps, null, 'getBuildStepAutotargetStepKey');
 
     // Make sure all the targets really exist.
     foreach ($autotargets as $autotarget) {
       if (empty($all_steps[$autotarget])) {
         throw new Exception(
           pht(
             'No build step provides autotarget "%s"!',
             $autotarget));
       }
     }
 
     return array_select_keys($all_steps, $autotargets);
   }
 
 
   /**
    * Get a list of @{class:HarbormasterBuildTarget} objects for a list of
    * autotarget keys.
    *
    * If some targets or builds do not exist, they are created.
    *
-   * @param HarbormasterBuildable A buildable.
-   * @param map<string, object> Map of keys to steps.
+   * @param HarbormasterBuildable $buildable A buildable.
+   * @param map<string, object> $step_map Map of keys to steps.
    * @return map<string, object> Map of keys to targets.
    */
   private function generateBuildTargetMap(
     HarbormasterBuildable $buildable,
     array $step_map) {
 
     $viewer = $this->getViewer();
     $initiator_phid = null;
     if (!$viewer->isOmnipotent()) {
       $initiator_phid = $viewer->getPHID();
     }
     $plan_map = mgroup($step_map, 'getBuildPlanPHID');
 
     $builds = id(new HarbormasterBuildQuery())
       ->setViewer($viewer)
       ->withBuildablePHIDs(array($buildable->getPHID()))
       ->withBuildPlanPHIDs(array_keys($plan_map))
       ->needBuildTargets(true)
       ->execute();
 
     $autobuilds = array();
     foreach ($builds as $build) {
       $plan_key = $build->getBuildPlan()->getPlanAutoKey();
       $autobuilds[$plan_key] = $build;
     }
 
     $new_builds = array();
     foreach ($plan_map as $plan_phid => $steps) {
       $plan = head($steps)->getBuildPlan();
       $plan_key = $plan->getPlanAutoKey();
 
       $build = idx($autobuilds, $plan_key);
       if ($build) {
         // We already have a build for this set of targets, so we don't need
         // to do any work. (It's possible the build is an older build that
         // doesn't have all of the right targets if new autotargets were
         // recently introduced, but we don't currently try to construct them.)
         continue;
       }
 
       // NOTE: Normally, `applyPlan()` does not actually generate targets.
       // We need to apply the plan in-process to perform target generation.
       // This is fine as long as autotargets are empty containers that don't
       // do any work, which they always should be.
 
       PhabricatorWorker::setRunAllTasksInProcess(true);
       try {
 
         // NOTE: We might race another process here to create the same build
         // with the same `planAutoKey`. The database will prevent this and
         // using autotargets only currently makes sense if you just created the
         // resource and "own" it, so we don't try to handle this, but may need
         // to be more careful here if use of autotargets expands.
 
         $build = $buildable->applyPlan($plan, array(), $initiator_phid);
         PhabricatorWorker::setRunAllTasksInProcess(false);
       } catch (Exception $ex) {
         PhabricatorWorker::setRunAllTasksInProcess(false);
         throw $ex;
       }
 
       $new_builds[] = $build;
     }
 
     if ($new_builds) {
       $all_targets = id(new HarbormasterBuildTargetQuery())
         ->setViewer($viewer)
         ->withBuildPHIDs(mpull($new_builds, 'getPHID'))
         ->execute();
     } else {
       $all_targets = array();
     }
 
     foreach ($builds as $build) {
       foreach ($build->getBuildTargets() as $target) {
         $all_targets[] = $target;
       }
     }
 
     $target_map = array();
     foreach ($all_targets as $target) {
       $target_key = $target
         ->getImplementation()
         ->getBuildStepAutotargetStepKey();
       if (!$target_key) {
         continue;
       }
       $target_map[$target_key] = $target;
     }
 
     $target_map = array_select_keys($target_map, array_keys($step_map));
 
     return $target_map;
   }
 
 
 }
diff --git a/src/applications/harbormaster/step/HarbormasterBuildStepImplementation.php b/src/applications/harbormaster/step/HarbormasterBuildStepImplementation.php
index 3bbb72cd95..851a020687 100644
--- a/src/applications/harbormaster/step/HarbormasterBuildStepImplementation.php
+++ b/src/applications/harbormaster/step/HarbormasterBuildStepImplementation.php
@@ -1,356 +1,358 @@
 <?php
 
 /**
  * @task autotarget Automatic Targets
  */
 abstract class HarbormasterBuildStepImplementation extends Phobject {
 
   private $settings;
   private $currentWorkerTaskID;
 
   public function setCurrentWorkerTaskID($id) {
     $this->currentWorkerTaskID = $id;
     return $this;
   }
 
   public function getCurrentWorkerTaskID() {
     return $this->currentWorkerTaskID;
   }
 
   public static function getImplementations() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->execute();
   }
 
   public static function getImplementation($class) {
     $base = idx(self::getImplementations(), $class);
 
     if ($base) {
       return (clone $base);
     }
 
     return null;
   }
 
   public static function requireImplementation($class) {
     if (!$class) {
       throw new Exception(pht('No implementation is specified!'));
     }
 
     $implementation = self::getImplementation($class);
     if (!$implementation) {
       throw new Exception(pht('No such implementation "%s" exists!', $class));
     }
 
     return $implementation;
   }
 
   /**
    * The name of the implementation.
    */
   abstract public function getName();
 
   public function getBuildStepGroupKey() {
     return HarbormasterOtherBuildStepGroup::GROUPKEY;
   }
 
   /**
    * The generic description of the implementation.
    */
   public function getGenericDescription() {
     return '';
   }
 
   /**
    * The description of the implementation, based on the current settings.
    */
   public function getDescription() {
     return $this->getGenericDescription();
   }
 
   public function getEditInstructions() {
     return null;
   }
 
   /**
    * Run the build target against the specified build.
    */
   abstract public function execute(
     HarbormasterBuild $build,
     HarbormasterBuildTarget $build_target);
 
   /**
    * Gets the settings for this build step.
    */
   public function getSettings() {
     return $this->settings;
   }
 
   public function getSetting($key, $default = null) {
     return idx($this->settings, $key, $default);
   }
 
   /**
    * Loads the settings for this build step implementation from a build
    * step or target.
    */
   final public function loadSettings($build_object) {
     $this->settings = $build_object->getDetails();
     return $this;
   }
 
   /**
    * Return the name of artifacts produced by this command.
    *
    * Future steps will calculate all available artifact mappings
    * before them and filter on the type.
    *
    * @return array The mappings of artifact names to their types.
    */
   public function getArtifactInputs() {
     return array();
   }
 
   public function getArtifactOutputs() {
     return array();
   }
 
   public function getDependencies(HarbormasterBuildStep $build_step) {
     $dependencies = $build_step->getDetail('dependsOn', array());
 
     $inputs = $build_step->getStepImplementation()->getArtifactInputs();
     $inputs = ipull($inputs, null, 'key');
 
     $artifacts = $this->getAvailableArtifacts(
       $build_step->getBuildPlan(),
       $build_step,
       null);
 
     foreach ($artifacts as $key => $type) {
       if (!array_key_exists($key, $inputs)) {
         unset($artifacts[$key]);
       }
     }
 
     $artifact_steps = ipull($artifacts, 'step');
     $artifact_steps = mpull($artifact_steps, 'getPHID');
 
     $dependencies = array_merge($dependencies, $artifact_steps);
 
     return $dependencies;
   }
 
   /**
    * Returns a list of all artifacts made available in the build plan.
    */
   public static function getAvailableArtifacts(
     HarbormasterBuildPlan $build_plan,
     $current_build_step,
     $artifact_type) {
 
     $steps = id(new HarbormasterBuildStepQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withBuildPlanPHIDs(array($build_plan->getPHID()))
       ->execute();
 
     $artifacts = array();
 
     $artifact_arrays = array();
     foreach ($steps as $step) {
       if ($current_build_step !== null &&
         $step->getPHID() === $current_build_step->getPHID()) {
 
         continue;
       }
 
       $implementation = $step->getStepImplementation();
       $array = $implementation->getArtifactOutputs();
       $array = ipull($array, 'type', 'key');
       foreach ($array as $name => $type) {
         if ($type !== $artifact_type && $artifact_type !== null) {
           continue;
         }
         $artifacts[$name] = array('type' => $type, 'step' => $step);
       }
     }
 
     return $artifacts;
   }
 
   /**
    * Convert a user-provided string with variables in it, like:
    *
    *   ls ${dirname}
    *
    * ...into a string with variables merged into it safely:
    *
    *   ls 'dir with spaces'
    *
-   * @param string Name of a `vxsprintf` function, like @{function:vcsprintf}.
-   * @param string User-provided pattern string containing `${variables}`.
-   * @param dict   List of available replacement variables.
+   * @param string $function Name of a `vxsprintf` function, like
+   *               @{function:vcsprintf}.
+   * @param string $pattern User-provided pattern string containing
+   *               `${variables}`.
+   * @param dict   $variables List of available replacement variables.
    * @return string String with variables replaced safely into it.
    */
   protected function mergeVariables($function, $pattern, array $variables) {
     $regexp = '@\\$\\{(?P<name>[a-z\\./_-]+)\\}@';
 
     $matches = null;
     preg_match_all($regexp, $pattern, $matches);
 
     $argv = array();
     foreach ($matches['name'] as $name) {
       if (!array_key_exists($name, $variables)) {
         throw new Exception(pht("No such variable '%s'!", $name));
       }
       $argv[] = $variables[$name];
     }
 
     $pattern = str_replace('%', '%%', $pattern);
     $pattern = preg_replace($regexp, '%s', $pattern);
 
     return call_user_func($function, $pattern, $argv);
   }
 
   public function getFieldSpecifications() {
     return array();
   }
 
   protected function formatSettingForDescription($key, $default = null) {
     return $this->formatValueForDescription($this->getSetting($key, $default));
   }
 
   protected function formatValueForDescription($value) {
     if (strlen($value)) {
       return phutil_tag('strong', array(), $value);
     } else {
       return phutil_tag('em', array(), pht('(null)'));
     }
   }
 
   public function supportsWaitForMessage() {
     return false;
   }
 
   public function shouldWaitForMessage(HarbormasterBuildTarget $target) {
     if (!$this->supportsWaitForMessage()) {
       return false;
     }
 
     $wait = $target->getDetail('builtin.wait-for-message');
     return ($wait == 'wait');
   }
 
   protected function shouldAbort(
     HarbormasterBuild $build,
     HarbormasterBuildTarget $target) {
 
     return $build->getBuildGeneration() !== $target->getBuildGeneration();
   }
 
   protected function resolveFutures(
     HarbormasterBuild $build,
     HarbormasterBuildTarget $target,
     array $futures) {
 
     $did_close = false;
     $wait_start = PhabricatorTime::getNow();
 
     $futures = new FutureIterator($futures);
     foreach ($futures->setUpdateInterval(5) as $key => $future) {
       if ($future !== null) {
         continue;
       }
 
       $build->reload();
       if ($this->shouldAbort($build, $target)) {
         throw new HarbormasterBuildAbortedException();
       }
 
       // See PHI916. If we're waiting on a remote system for a while, clean
       // up database connections to reduce the cost of having a large number
       // of processes babysitting an `ssh ... ./run-huge-build.sh` process on
       // a build host.
       if (!$did_close) {
         $now = PhabricatorTime::getNow();
         $elapsed = ($now - $wait_start);
         $idle_limit = 5;
 
         if ($elapsed >= $idle_limit) {
           LiskDAO::closeIdleConnections();
           $did_close = true;
         }
       }
     }
 
   }
 
   protected function logHTTPResponse(
     HarbormasterBuild $build,
     HarbormasterBuildTarget $build_target,
     BaseHTTPFuture $future,
     $label) {
 
     list($status, $body, $headers) = $future->resolve();
 
     $header_lines = array();
 
     // TODO: We don't currently preserve the entire "HTTP" response header, but
     // should. Once we do, reproduce it here faithfully.
     $status_code = $status->getStatusCode();
     $header_lines[] = "HTTP {$status_code}";
 
     foreach ($headers as $header) {
       list($head, $tail) = $header;
       $header_lines[] = "{$head}: {$tail}";
     }
     $header_lines = implode("\n", $header_lines);
 
     $build_target
       ->newLog($label, 'http.head')
       ->append($header_lines);
 
     $build_target
       ->newLog($label, 'http.body')
       ->append($body);
   }
 
   protected function logSilencedCall(
     HarbormasterBuild $build,
     HarbormasterBuildTarget $build_target,
     $label) {
 
     $build_target
       ->newLog($label, 'silenced')
       ->append(
         pht(
           'Declining to make service call because `phabricator.silent` is '.
           'enabled in configuration.'));
   }
 
   public function willStartBuild(
     PhabricatorUser $viewer,
     HarbormasterBuildable $buildable,
     HarbormasterBuild $build,
     HarbormasterBuildPlan $plan,
     HarbormasterBuildStep $step) {
     return;
   }
 
 
 /* -(  Automatic Targets  )-------------------------------------------------- */
 
 
   public function getBuildStepAutotargetStepKey() {
     return null;
   }
 
   public function getBuildStepAutotargetPlanKey() {
     throw new PhutilMethodNotImplementedException();
   }
 
   public function shouldRequireAutotargeting() {
     return false;
   }
 
 }
diff --git a/src/applications/harbormaster/storage/HarbormasterBuildable.php b/src/applications/harbormaster/storage/HarbormasterBuildable.php
index df8451d842..2d085ce523 100644
--- a/src/applications/harbormaster/storage/HarbormasterBuildable.php
+++ b/src/applications/harbormaster/storage/HarbormasterBuildable.php
@@ -1,421 +1,421 @@
 <?php
 
 final class HarbormasterBuildable
   extends HarbormasterDAO
   implements
     PhabricatorApplicationTransactionInterface,
     PhabricatorPolicyInterface,
     HarbormasterBuildableInterface,
     PhabricatorConduitResultInterface,
     PhabricatorDestructibleInterface {
 
   protected $buildablePHID;
   protected $containerPHID;
   protected $buildableStatus;
   protected $isManualBuildable;
 
   private $buildableObject = self::ATTACHABLE;
   private $containerObject = self::ATTACHABLE;
   private $builds = self::ATTACHABLE;
 
   public static function initializeNewBuildable(PhabricatorUser $actor) {
     return id(new HarbormasterBuildable())
       ->setIsManualBuildable(0)
       ->setBuildableStatus(HarbormasterBuildableStatus::STATUS_PREPARING);
   }
 
   public function getMonogram() {
     return 'B'.$this->getID();
   }
 
   public function getURI() {
     return '/'.$this->getMonogram();
   }
 
   /**
    * Returns an existing buildable for the object's PHID or creates a
    * new buildable implicitly if needed.
    */
   public static function createOrLoadExisting(
     PhabricatorUser $actor,
     $buildable_object_phid,
     $container_object_phid) {
 
     $buildable = id(new HarbormasterBuildableQuery())
       ->setViewer($actor)
       ->withBuildablePHIDs(array($buildable_object_phid))
       ->withManualBuildables(false)
       ->setLimit(1)
       ->executeOne();
     if ($buildable) {
       return $buildable;
     }
     $buildable = self::initializeNewBuildable($actor)
       ->setBuildablePHID($buildable_object_phid)
       ->setContainerPHID($container_object_phid);
     $buildable->save();
     return $buildable;
   }
 
   /**
    * Start builds for a given buildable.
    *
-   * @param phid PHID of the object to build.
-   * @param phid Container PHID for the buildable.
-   * @param list<HarbormasterBuildRequest> List of builds to perform.
+   * @param phid $phid PHID of the object to build.
+   * @param phid $container_phid Container PHID for the buildable.
+   * @param list<HarbormasterBuildRequest> $requests List of builds to perform.
    * @return void
    */
   public static function applyBuildPlans(
     $phid,
     $container_phid,
     array $requests) {
 
     assert_instances_of($requests, 'HarbormasterBuildRequest');
 
     if (!$requests) {
       return;
     }
 
     // Skip all of this logic if the Harbormaster application
     // isn't currently installed.
 
     $harbormaster_app = 'PhabricatorHarbormasterApplication';
     if (!PhabricatorApplication::isClassInstalled($harbormaster_app)) {
       return;
     }
 
     $viewer = PhabricatorUser::getOmnipotentUser();
 
     $buildable = self::createOrLoadExisting(
       $viewer,
       $phid,
       $container_phid);
 
     $plan_phids = mpull($requests, 'getBuildPlanPHID');
     $plans = id(new HarbormasterBuildPlanQuery())
       ->setViewer($viewer)
       ->withPHIDs($plan_phids)
       ->execute();
     $plans = mpull($plans, null, 'getPHID');
 
     foreach ($requests as $request) {
       $plan_phid = $request->getBuildPlanPHID();
       $plan = idx($plans, $plan_phid);
 
       if (!$plan) {
         throw new Exception(
           pht(
             'Failed to load build plan ("%s").',
             $plan_phid));
       }
 
       if ($plan->isDisabled()) {
         // TODO: This should be communicated more clearly -- maybe we should
         // create the build but set the status to "disabled" or "derelict".
         continue;
       }
 
       $parameters = $request->getBuildParameters();
       $buildable->applyPlan($plan, $parameters, $request->getInitiatorPHID());
     }
   }
 
   public function applyPlan(
     HarbormasterBuildPlan $plan,
     array $parameters,
     $initiator_phid) {
 
     $viewer = PhabricatorUser::getOmnipotentUser();
     $build = HarbormasterBuild::initializeNewBuild($viewer)
       ->setBuildablePHID($this->getPHID())
       ->setBuildPlanPHID($plan->getPHID())
       ->setBuildParameters($parameters)
       ->setBuildStatus(HarbormasterBuildStatus::STATUS_PENDING);
     if ($initiator_phid) {
       $build->setInitiatorPHID($initiator_phid);
     }
 
     $auto_key = $plan->getPlanAutoKey();
     if ($auto_key) {
       $build->setPlanAutoKey($auto_key);
     }
 
     $build->save();
 
     $steps = id(new HarbormasterBuildStepQuery())
       ->setViewer($viewer)
       ->withBuildPlanPHIDs(array($plan->getPHID()))
       ->execute();
 
     foreach ($steps as $step) {
       $step->willStartBuild($viewer, $this, $build, $plan);
     }
 
     PhabricatorWorker::scheduleTask(
       'HarbormasterBuildWorker',
       array(
         'buildID' => $build->getID(),
       ),
       array(
         'objectPHID' => $build->getPHID(),
       ));
 
     return $build;
   }
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_COLUMN_SCHEMA => array(
         'containerPHID' => 'phid?',
         'buildableStatus' => 'text32',
         'isManualBuildable' => 'bool',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_buildable' => array(
           'columns' => array('buildablePHID'),
         ),
         'key_container' => array(
           'columns' => array('containerPHID'),
         ),
         'key_manual' => array(
           'columns' => array('isManualBuildable'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function generatePHID() {
     return PhabricatorPHID::generateNewPHID(
       HarbormasterBuildablePHIDType::TYPECONST);
   }
 
   public function attachBuildableObject($buildable_object) {
     $this->buildableObject = $buildable_object;
     return $this;
   }
 
   public function getBuildableObject() {
     return $this->assertAttached($this->buildableObject);
   }
 
   public function attachContainerObject($container_object) {
     $this->containerObject = $container_object;
     return $this;
   }
 
   public function getContainerObject() {
     return $this->assertAttached($this->containerObject);
   }
 
   public function attachBuilds(array $builds) {
     assert_instances_of($builds, 'HarbormasterBuild');
     $this->builds = $builds;
     return $this;
   }
 
   public function getBuilds() {
     return $this->assertAttached($this->builds);
   }
 
 
 /* -(  Status  )------------------------------------------------------------- */
 
 
   public function getBuildableStatusObject() {
     $status = $this->getBuildableStatus();
     return HarbormasterBuildableStatus::newBuildableStatusObject($status);
   }
 
   public function getStatusIcon() {
     return $this->getBuildableStatusObject()->getIcon();
   }
 
   public function getStatusDisplayName() {
     return $this->getBuildableStatusObject()->getDisplayName();
   }
 
   public function getStatusColor() {
     return $this->getBuildableStatusObject()->getColor();
   }
 
   public function isPreparing() {
     return $this->getBuildableStatusObject()->isPreparing();
   }
 
   public function isBuilding() {
     return $this->getBuildableStatusObject()->isBuilding();
   }
 
 
 /* -(  Messages  )----------------------------------------------------------- */
 
 
   public function sendMessage(
     PhabricatorUser $viewer,
     $message_type,
     $queue_update) {
 
     $message = HarbormasterBuildMessage::initializeNewMessage($viewer)
       ->setReceiverPHID($this->getPHID())
       ->setType($message_type)
       ->save();
 
     if ($queue_update) {
       PhabricatorWorker::scheduleTask(
         'HarbormasterBuildWorker',
         array(
           'buildablePHID' => $this->getPHID(),
         ),
         array(
           'objectPHID' => $this->getPHID(),
         ));
     }
 
     return $message;
   }
 
 
 /* -(  PhabricatorApplicationTransactionInterface  )------------------------- */
 
 
   public function getApplicationTransactionEditor() {
     return new HarbormasterBuildableTransactionEditor();
   }
 
   public function getApplicationTransactionTemplate() {
     return new HarbormasterBuildableTransaction();
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     return $this->getBuildableObject()->getPolicy($capability);
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return $this->getBuildableObject()->hasAutomaticCapability(
       $capability,
       $viewer);
   }
 
   public function describeAutomaticCapability($capability) {
     return pht('A buildable inherits policies from the underlying object.');
   }
 
 
 
 /* -(  HarbormasterBuildableInterface  )------------------------------------- */
 
 
   public function getHarbormasterBuildableDisplayPHID() {
     return $this->getBuildableObject()->getHarbormasterBuildableDisplayPHID();
   }
 
   public function getHarbormasterBuildablePHID() {
     // NOTE: This is essentially just for convenience, as it allows you create
     // a copy of a buildable by specifying `B123` without bothering to go
     // look up the underlying object.
     return $this->getBuildablePHID();
   }
 
   public function getHarbormasterContainerPHID() {
     return $this->getContainerPHID();
   }
 
   public function getBuildVariables() {
     return array();
   }
 
   public function getAvailableBuildVariables() {
     return array();
   }
 
   public function newBuildableEngine() {
     return $this->getBuildableObject()->newBuildableEngine();
   }
 
 
 /* -(  PhabricatorConduitResultInterface  )---------------------------------- */
 
 
   public function getFieldSpecificationsForConduit() {
     return array(
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('objectPHID')
         ->setType('phid')
         ->setDescription(pht('PHID of the object that is built.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('containerPHID')
         ->setType('phid')
         ->setDescription(pht('PHID of the object containing this buildable.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('buildableStatus')
         ->setType('map<string, wild>')
         ->setDescription(pht('The current status of this buildable.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('isManual')
         ->setType('bool')
         ->setDescription(pht('True if this is a manual buildable.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('uri')
         ->setType('uri')
         ->setDescription(pht('View URI for the buildable.')),
     );
   }
 
   public function getFieldValuesForConduit() {
     return array(
       'objectPHID' => $this->getBuildablePHID(),
       'containerPHID' => $this->getContainerPHID(),
       'buildableStatus' => array(
         'value' => $this->getBuildableStatus(),
       ),
       'isManual' => (bool)$this->getIsManualBuildable(),
       'uri' => PhabricatorEnv::getURI($this->getURI()),
     );
   }
 
   public function getConduitSearchAttachments() {
     return array();
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
     $viewer = $engine->getViewer();
 
     $this->openTransaction();
       $builds = id(new HarbormasterBuildQuery())
         ->setViewer($viewer)
         ->withBuildablePHIDs(array($this->getPHID()))
         ->execute();
       foreach ($builds as $build) {
         $engine->destroyObject($build);
       }
 
       $messages = id(new HarbormasterBuildMessageQuery())
         ->setViewer($viewer)
         ->withReceiverPHIDs(array($this->getPHID()))
         ->execute();
       foreach ($messages as $message) {
         $engine->destroyObject($message);
       }
 
       $this->delete();
     $this->saveTransaction();
   }
 
 }
diff --git a/src/applications/herald/adapter/HeraldAdapter.php b/src/applications/herald/adapter/HeraldAdapter.php
index c04a10ea51..8824f645e3 100644
--- a/src/applications/herald/adapter/HeraldAdapter.php
+++ b/src/applications/herald/adapter/HeraldAdapter.php
@@ -1,1214 +1,1215 @@
 <?php
 
 abstract class HeraldAdapter extends Phobject {
 
   const CONDITION_CONTAINS        = 'contains';
   const CONDITION_NOT_CONTAINS    = '!contains';
   const CONDITION_IS              = 'is';
   const CONDITION_IS_NOT          = '!is';
   const CONDITION_IS_ANY          = 'isany';
   const CONDITION_IS_NOT_ANY      = '!isany';
   const CONDITION_INCLUDE_ALL     = 'all';
   const CONDITION_INCLUDE_ANY     = 'any';
   const CONDITION_INCLUDE_NONE    = 'none';
   const CONDITION_IS_ME           = 'me';
   const CONDITION_IS_NOT_ME       = '!me';
   const CONDITION_REGEXP          = 'regexp';
   const CONDITION_NOT_REGEXP      = '!regexp';
   const CONDITION_RULE            = 'conditions';
   const CONDITION_NOT_RULE        = '!conditions';
   const CONDITION_EXISTS          = 'exists';
   const CONDITION_NOT_EXISTS      = '!exists';
   const CONDITION_UNCONDITIONALLY = 'unconditionally';
   const CONDITION_NEVER           = 'never';
   const CONDITION_REGEXP_PAIR     = 'regexp-pair';
   const CONDITION_HAS_BIT         = 'bit';
   const CONDITION_NOT_BIT         = '!bit';
   const CONDITION_IS_TRUE         = 'true';
   const CONDITION_IS_FALSE        = 'false';
 
   private $contentSource;
   private $isNewObject;
   private $applicationEmail;
   private $appliedTransactions = array();
   private $queuedTransactions = array();
   private $emailPHIDs = array();
   private $forcedEmailPHIDs = array();
   private $fieldMap;
   private $actionMap;
   private $edgeCache = array();
   private $forbiddenActions = array();
   private $viewer;
   private $mustEncryptReasons = array();
   private $actingAsPHID;
   private $webhookMap = array();
 
   public function getEmailPHIDs() {
     return array_values($this->emailPHIDs);
   }
 
   public function getForcedEmailPHIDs() {
     return array_values($this->forcedEmailPHIDs);
   }
 
   final public function setActingAsPHID($acting_as_phid) {
     $this->actingAsPHID = $acting_as_phid;
     return $this;
   }
 
   final public function getActingAsPHID() {
     return $this->actingAsPHID;
   }
 
   public function addEmailPHID($phid, $force) {
     $this->emailPHIDs[$phid] = $phid;
     if ($force) {
       $this->forcedEmailPHIDs[$phid] = $phid;
     }
     return $this;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     // See PHI276. Normally, Herald runs without regard for policy checks.
     // However, we use a real viewer during test console runs: this makes
     // intracluster calls to Diffusion APIs work even if web nodes don't
     // have privileged credentials.
 
     if ($this->viewer) {
       return $this->viewer;
     }
 
     return PhabricatorUser::getOmnipotentUser();
   }
 
   public function setContentSource(PhabricatorContentSource $content_source) {
     $this->contentSource = $content_source;
     return $this;
   }
 
   public function getContentSource() {
     return $this->contentSource;
   }
 
   public function getIsNewObject() {
     if (is_bool($this->isNewObject)) {
       return $this->isNewObject;
     }
 
     throw new Exception(
       pht(
         'You must %s to a boolean first!',
         'setIsNewObject()'));
   }
   public function setIsNewObject($new) {
     $this->isNewObject = (bool)$new;
     return $this;
   }
 
   public function supportsApplicationEmail() {
     return false;
   }
 
   public function setApplicationEmail(
     PhabricatorMetaMTAApplicationEmail $email) {
     $this->applicationEmail = $email;
     return $this;
   }
 
   public function getApplicationEmail() {
     return $this->applicationEmail;
   }
 
   public function getPHID() {
     return $this->getObject()->getPHID();
   }
 
   abstract public function getHeraldName();
 
   final public function willGetHeraldField($field_key) {
     // This method is called during rule evaluation, before we engage the
     // Herald profiler. We make sure we have a concrete implementation so time
     // spent loading fields out of the classmap is not mistakenly attributed to
     // whichever field happens to evaluate first.
     $this->requireFieldImplementation($field_key);
   }
 
   public function getHeraldField($field_key) {
     return $this->requireFieldImplementation($field_key)
       ->getHeraldFieldValue($this->getObject());
   }
 
   public function applyHeraldEffects(array $effects) {
     assert_instances_of($effects, 'HeraldEffect');
 
     $result = array();
     foreach ($effects as $effect) {
       $result[] = $this->applyStandardEffect($effect);
     }
 
     return $result;
   }
 
   public function isAvailableToUser(PhabricatorUser $viewer) {
     $applications = id(new PhabricatorApplicationQuery())
       ->setViewer($viewer)
       ->withInstalled(true)
       ->withClasses(array($this->getAdapterApplicationClass()))
       ->execute();
 
     return !empty($applications);
   }
 
 
   /**
    * Set the list of transactions which just took effect.
    *
    * These transactions are set by @{class:PhabricatorApplicationEditor}
    * automatically, before it invokes Herald.
    *
-   * @param list<PhabricatorApplicationTransaction> List of transactions.
+   * @param list<PhabricatorApplicationTransaction> $xactions List of
+   *   transactions.
    * @return this
    */
   final public function setAppliedTransactions(array $xactions) {
     assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
     $this->appliedTransactions = $xactions;
     return $this;
   }
 
 
   /**
    * Get a list of transactions which just took effect.
    *
    * When an object is edited normally, transactions are applied and then
    * Herald executes. You can call this method to examine the transactions
    * if you want to react to them.
    *
    * @return list<PhabricatorApplicationTransaction> List of transactions.
    */
   final public function getAppliedTransactions() {
     return $this->appliedTransactions;
   }
 
   final public function queueTransaction(
     PhabricatorApplicationTransaction $transaction) {
     $this->queuedTransactions[] = $transaction;
   }
 
   final public function getQueuedTransactions() {
     return $this->queuedTransactions;
   }
 
   final public function newTransaction() {
     $object = $this->newObject();
 
     if (!($object instanceof PhabricatorApplicationTransactionInterface)) {
       throw new Exception(
         pht(
           'Unable to build a new transaction for adapter object; it does '.
           'not implement "%s".',
           'PhabricatorApplicationTransactionInterface'));
     }
 
     $xaction = $object->getApplicationTransactionTemplate();
 
     if (!($xaction instanceof PhabricatorApplicationTransaction)) {
       throw new Exception(
         pht(
           'Expected object (of class "%s") to return a transaction template '.
           '(of class "%s"), but it returned something else ("%s").',
           get_class($object),
           'PhabricatorApplicationTransaction',
           phutil_describe_type($xaction)));
     }
 
     return $xaction;
   }
 
 
   /**
    * NOTE: You generally should not override this; it exists to support legacy
    * adapters which had hard-coded content types.
    */
   public function getAdapterContentType() {
     return get_class($this);
   }
 
   abstract public function getAdapterContentName();
   abstract public function getAdapterContentDescription();
   abstract public function getAdapterApplicationClass();
   abstract public function getObject();
 
   public function getAdapterContentIcon() {
     $application_class = $this->getAdapterApplicationClass();
     $application = newv($application_class, array());
     return $application->getIcon();
   }
 
   /**
    * Return a new characteristic object for this adapter.
    *
    * The adapter will use this object to test for interfaces, generate
    * transactions, and interact with custom fields.
    *
    * Adapters must return an object from this method to enable custom
    * field rules and various implicit actions.
    *
    * Normally, you'll return an empty version of the adapted object:
    *
    *   return new ApplicationObject();
    *
    * @return null|object Template object.
    */
   protected function newObject() {
     return null;
   }
 
   public function supportsRuleType($rule_type) {
     return false;
   }
 
   public function canTriggerOnObject($object) {
     return false;
   }
 
   public function isTestAdapterForObject($object) {
     return false;
   }
 
   public function canCreateTestAdapterForObject($object) {
     return $this->isTestAdapterForObject($object);
   }
 
   public function newTestAdapter(PhabricatorUser $viewer, $object) {
     return id(clone $this)
       ->setObject($object);
   }
 
   public function getAdapterTestDescription() {
     return null;
   }
 
   public function explainValidTriggerObjects() {
     return pht('This adapter can not trigger on objects.');
   }
 
   public function getTriggerObjectPHIDs() {
     return array($this->getPHID());
   }
 
   public function getAdapterSortKey() {
     return sprintf(
       '%08d%s',
       $this->getAdapterSortOrder(),
       $this->getAdapterContentName());
   }
 
   public function getAdapterSortOrder() {
     return 1000;
   }
 
 
 /* -(  Fields  )------------------------------------------------------------- */
 
   private function getFieldImplementationMap() {
     if ($this->fieldMap === null) {
       // We can't use PhutilClassMapQuery here because field expansion
       // depends on the adapter and object.
 
       $object = $this->getObject();
 
       $map = array();
       $all = HeraldField::getAllFields();
       foreach ($all as $key => $field) {
         $field = id(clone $field)->setAdapter($this);
 
         if (!$field->supportsObject($object)) {
           continue;
         }
         $subfields = $field->getFieldsForObject($object);
         foreach ($subfields as $subkey => $subfield) {
           if (isset($map[$subkey])) {
             throw new Exception(
               pht(
                 'Two HeraldFields (of classes "%s" and "%s") have the same '.
                 'field key ("%s") after expansion for an object of class '.
                 '"%s" inside adapter "%s". Each field must have a unique '.
                 'field key.',
                 get_class($subfield),
                 get_class($map[$subkey]),
                 $subkey,
                 get_class($object),
                 get_class($this)));
           }
 
           $subfield = id(clone $subfield)->setAdapter($this);
 
           $map[$subkey] = $subfield;
         }
       }
       $this->fieldMap = $map;
     }
 
     return $this->fieldMap;
   }
 
   private function getFieldImplementation($key) {
     return idx($this->getFieldImplementationMap(), $key);
   }
 
   public function getFields() {
     return array_keys($this->getFieldImplementationMap());
   }
 
   public function getFieldNameMap() {
     return mpull($this->getFieldImplementationMap(), 'getHeraldFieldName');
   }
 
   public function getFieldGroupKey($field_key) {
     $field = $this->getFieldImplementation($field_key);
 
     if (!$field) {
       return null;
     }
 
     return $field->getFieldGroupKey();
   }
 
   public function isFieldAvailable($field_key) {
     $field = $this->getFieldImplementation($field_key);
 
     if (!$field) {
       return null;
     }
 
     return $field->isFieldAvailable();
   }
 
 
 /* -(  Conditions  )--------------------------------------------------------- */
 
 
   public function getConditionNameMap() {
     return array(
       self::CONDITION_CONTAINS        => pht('contains'),
       self::CONDITION_NOT_CONTAINS    => pht('does not contain'),
       self::CONDITION_IS              => pht('is'),
       self::CONDITION_IS_NOT          => pht('is not'),
       self::CONDITION_IS_ANY          => pht('is any of'),
       self::CONDITION_IS_TRUE         => pht('is true'),
       self::CONDITION_IS_FALSE        => pht('is false'),
       self::CONDITION_IS_NOT_ANY      => pht('is not any of'),
       self::CONDITION_INCLUDE_ALL     => pht('include all of'),
       self::CONDITION_INCLUDE_ANY     => pht('include any of'),
       self::CONDITION_INCLUDE_NONE    => pht('include none of'),
       self::CONDITION_IS_ME           => pht('is myself'),
       self::CONDITION_IS_NOT_ME       => pht('is not myself'),
       self::CONDITION_REGEXP          => pht('matches regexp'),
       self::CONDITION_NOT_REGEXP      => pht('does not match regexp'),
       self::CONDITION_RULE            => pht('matches:'),
       self::CONDITION_NOT_RULE        => pht('does not match:'),
       self::CONDITION_EXISTS          => pht('exists'),
       self::CONDITION_NOT_EXISTS      => pht('does not exist'),
       self::CONDITION_UNCONDITIONALLY => '',  // don't show anything!
       self::CONDITION_NEVER           => '',  // don't show anything!
       self::CONDITION_REGEXP_PAIR     => pht('matches regexp pair'),
       self::CONDITION_HAS_BIT         => pht('has bit'),
       self::CONDITION_NOT_BIT         => pht('lacks bit'),
     );
   }
 
   public function getConditionsForField($field) {
     return $this->requireFieldImplementation($field)
       ->getHeraldFieldConditions();
   }
 
   private function requireFieldImplementation($field_key) {
     $field = $this->getFieldImplementation($field_key);
 
     if (!$field) {
       throw new Exception(
         pht(
           'No field with key "%s" is available to Herald adapter "%s".',
           $field_key,
           get_class($this)));
     }
 
     return $field;
   }
 
   public function doesConditionMatch(
     HeraldEngine $engine,
     HeraldRule $rule,
     HeraldCondition $condition,
     $field_value) {
 
     $condition_type = $condition->getFieldCondition();
     $condition_value = $condition->getValue();
 
     switch ($condition_type) {
       case self::CONDITION_CONTAINS:
       case self::CONDITION_NOT_CONTAINS:
         // "Contains and "does not contain" can take an array of strings, as in
         // "Any changed filename" for diffs.
 
         $result_if_match = ($condition_type == self::CONDITION_CONTAINS);
 
         foreach ((array)$field_value as $value) {
           if (stripos($value, $condition_value) !== false) {
             return $result_if_match;
           }
         }
         return !$result_if_match;
       case self::CONDITION_IS:
         return ($field_value == $condition_value);
       case self::CONDITION_IS_NOT:
         return ($field_value != $condition_value);
       case self::CONDITION_IS_ME:
         return ($field_value == $rule->getAuthorPHID());
       case self::CONDITION_IS_NOT_ME:
         return ($field_value != $rule->getAuthorPHID());
       case self::CONDITION_IS_ANY:
         if (!is_array($condition_value)) {
           throw new HeraldInvalidConditionException(
             pht('Expected condition value to be an array.'));
         }
         $condition_value = array_fuse($condition_value);
         return isset($condition_value[$field_value]);
       case self::CONDITION_IS_NOT_ANY:
         if (!is_array($condition_value)) {
           throw new HeraldInvalidConditionException(
             pht('Expected condition value to be an array.'));
         }
         $condition_value = array_fuse($condition_value);
         return !isset($condition_value[$field_value]);
       case self::CONDITION_INCLUDE_ALL:
         if (!is_array($field_value)) {
           throw new HeraldInvalidConditionException(
             pht('Object produced non-array value!'));
         }
         if (!is_array($condition_value)) {
           throw new HeraldInvalidConditionException(
             pht('Expected condition value to be an array.'));
         }
 
         $have = array_select_keys(array_fuse($field_value), $condition_value);
         return (count($have) == count($condition_value));
       case self::CONDITION_INCLUDE_ANY:
         return (bool)array_select_keys(
           array_fuse($field_value),
           $condition_value);
       case self::CONDITION_INCLUDE_NONE:
         return !array_select_keys(
           array_fuse($field_value),
           $condition_value);
       case self::CONDITION_EXISTS:
       case self::CONDITION_IS_TRUE:
         return (bool)$field_value;
       case self::CONDITION_NOT_EXISTS:
       case self::CONDITION_IS_FALSE:
         return !$field_value;
       case self::CONDITION_UNCONDITIONALLY:
         return (bool)$field_value;
       case self::CONDITION_NEVER:
         return false;
       case self::CONDITION_REGEXP:
       case self::CONDITION_NOT_REGEXP:
         $result_if_match = ($condition_type == self::CONDITION_REGEXP);
 
         // We add the 'S' flag because we use the regexp multiple times.
         // It shouldn't cause any troubles if the flag is already there
         // - /.*/S is evaluated same as /.*/SS.
         $condition_pattern = $condition_value.'S';
 
         foreach ((array)$field_value as $value) {
           try {
             $result = phutil_preg_match($condition_pattern, $value);
           } catch (PhutilRegexException $ex) {
             $message = array();
             $message[] = pht(
               'Regular expression "%s" in Herald rule "%s" is not valid, '.
               'or exceeded backtracking or recursion limits while '.
               'executing. Verify the expression and correct it or rewrite '.
               'it with less backtracking.',
               $condition_value,
               $rule->getMonogram());
             $message[] = $ex->getMessage();
             $message = implode("\n\n", $message);
 
             throw new HeraldInvalidConditionException($message);
           }
 
           if ($result) {
             return $result_if_match;
           }
         }
         return !$result_if_match;
       case self::CONDITION_REGEXP_PAIR:
         // Match a JSON-encoded pair of regular expressions against a
         // dictionary. The first regexp must match the dictionary key, and the
         // second regexp must match the dictionary value. If any key/value pair
         // in the dictionary matches both regexps, the condition is satisfied.
         $regexp_pair = null;
         try {
           $regexp_pair = phutil_json_decode($condition_value);
         } catch (PhutilJSONParserException $ex) {
           throw new HeraldInvalidConditionException(
             pht('Regular expression pair is not valid JSON!'));
         }
         if (count($regexp_pair) != 2) {
           throw new HeraldInvalidConditionException(
             pht('Regular expression pair is not a pair!'));
         }
 
         $key_regexp   = array_shift($regexp_pair);
         $value_regexp = array_shift($regexp_pair);
 
         foreach ((array)$field_value as $key => $value) {
           $key_matches = @preg_match($key_regexp, $key);
           if ($key_matches === false) {
             throw new HeraldInvalidConditionException(
               pht('First regular expression is invalid!'));
           }
           if ($key_matches) {
             $value_matches = @preg_match($value_regexp, $value);
             if ($value_matches === false) {
               throw new HeraldInvalidConditionException(
                 pht('Second regular expression is invalid!'));
             }
             if ($value_matches) {
               return true;
             }
           }
         }
         return false;
       case self::CONDITION_RULE:
       case self::CONDITION_NOT_RULE:
         $rule = $engine->getRule($condition_value);
         if (!$rule) {
           throw new HeraldInvalidConditionException(
             pht('Condition references a rule which does not exist!'));
         }
 
         $is_not = ($condition_type == self::CONDITION_NOT_RULE);
         $result = $engine->doesRuleMatch($rule, $this);
         if ($is_not) {
           $result = !$result;
         }
         return $result;
       case self::CONDITION_HAS_BIT:
         return (($condition_value & $field_value) === (int)$condition_value);
       case self::CONDITION_NOT_BIT:
         return (($condition_value & $field_value) !== (int)$condition_value);
       default:
         throw new HeraldInvalidConditionException(
           pht("Unknown condition '%s'.", $condition_type));
     }
   }
 
   public function willSaveCondition(HeraldCondition $condition) {
     $condition_type = $condition->getFieldCondition();
     $condition_value = $condition->getValue();
 
     switch ($condition_type) {
       case self::CONDITION_REGEXP:
       case self::CONDITION_NOT_REGEXP:
         $ok = @preg_match($condition_value, '');
         if ($ok === false) {
           throw new HeraldInvalidConditionException(
             pht(
               'The regular expression "%s" is not valid. Regular expressions '.
               'must have enclosing characters (e.g. "@/path/to/file@", not '.
               '"/path/to/file") and be syntactically correct.',
               $condition_value));
         }
         break;
       case self::CONDITION_REGEXP_PAIR:
         $json = null;
         try {
           $json = phutil_json_decode($condition_value);
         } catch (PhutilJSONParserException $ex) {
           throw new HeraldInvalidConditionException(
             pht(
               'The regular expression pair "%s" is not valid JSON. Enter a '.
               'valid JSON array with two elements.',
               $condition_value));
         }
 
         if (count($json) != 2) {
           throw new HeraldInvalidConditionException(
             pht(
               'The regular expression pair "%s" must have exactly two '.
               'elements.',
               $condition_value));
         }
 
         $key_regexp = array_shift($json);
         $val_regexp = array_shift($json);
 
         $key_ok = @preg_match($key_regexp, '');
         if ($key_ok === false) {
           throw new HeraldInvalidConditionException(
             pht(
               'The first regexp in the regexp pair, "%s", is not a valid '.
               'regexp.',
               $key_regexp));
         }
 
         $val_ok = @preg_match($val_regexp, '');
         if ($val_ok === false) {
           throw new HeraldInvalidConditionException(
             pht(
               'The second regexp in the regexp pair, "%s", is not a valid '.
               'regexp.',
               $val_regexp));
         }
         break;
       case self::CONDITION_CONTAINS:
       case self::CONDITION_NOT_CONTAINS:
       case self::CONDITION_IS:
       case self::CONDITION_IS_NOT:
       case self::CONDITION_IS_ANY:
       case self::CONDITION_IS_NOT_ANY:
       case self::CONDITION_INCLUDE_ALL:
       case self::CONDITION_INCLUDE_ANY:
       case self::CONDITION_INCLUDE_NONE:
       case self::CONDITION_IS_ME:
       case self::CONDITION_IS_NOT_ME:
       case self::CONDITION_RULE:
       case self::CONDITION_NOT_RULE:
       case self::CONDITION_EXISTS:
       case self::CONDITION_NOT_EXISTS:
       case self::CONDITION_UNCONDITIONALLY:
       case self::CONDITION_NEVER:
       case self::CONDITION_HAS_BIT:
       case self::CONDITION_NOT_BIT:
       case self::CONDITION_IS_TRUE:
       case self::CONDITION_IS_FALSE:
         // No explicit validation for these types, although there probably
         // should be in some cases.
         break;
       default:
         throw new HeraldInvalidConditionException(
           pht(
             'Unknown condition "%s"!',
             $condition_type));
     }
   }
 
 
 /* -(  Actions  )------------------------------------------------------------ */
 
   private function getActionImplementationMap() {
     if ($this->actionMap === null) {
       // We can't use PhutilClassMapQuery here because action expansion
       // depends on the adapter and object.
 
       $object = $this->getObject();
 
       $map = array();
       $all = HeraldAction::getAllActions();
       foreach ($all as $key => $action) {
         $action = id(clone $action)->setAdapter($this);
 
         if (!$action->supportsObject($object)) {
           continue;
         }
 
         $subactions = $action->getActionsForObject($object);
         foreach ($subactions as $subkey => $subaction) {
           if (isset($map[$subkey])) {
             throw new Exception(
               pht(
                 'Two HeraldActions (of classes "%s" and "%s") have the same '.
                 'action key ("%s") after expansion for an object of class '.
                 '"%s" inside adapter "%s". Each action must have a unique '.
                 'action key.',
                 get_class($subaction),
                 get_class($map[$subkey]),
                 $subkey,
                 get_class($object),
                 get_class($this)));
           }
 
           $subaction = id(clone $subaction)->setAdapter($this);
 
           $map[$subkey] = $subaction;
         }
       }
       $this->actionMap = $map;
     }
 
     return $this->actionMap;
   }
 
   private function requireActionImplementation($action_key) {
     $action = $this->getActionImplementation($action_key);
 
     if (!$action) {
       throw new Exception(
         pht(
           'No action with key "%s" is available to Herald adapter "%s".',
           $action_key,
           get_class($this)));
     }
 
     return $action;
   }
 
   private function getActionsForRuleType($rule_type) {
     $actions = $this->getActionImplementationMap();
 
     foreach ($actions as $key => $action) {
       if (!$action->supportsRuleType($rule_type)) {
         unset($actions[$key]);
       }
     }
 
     return $actions;
   }
 
   public function getActionImplementation($key) {
     return idx($this->getActionImplementationMap(), $key);
   }
 
   public function getActionKeys() {
     return array_keys($this->getActionImplementationMap());
   }
 
   public function getActionGroupKey($action_key) {
     $action = $this->getActionImplementation($action_key);
     if (!$action) {
       return null;
     }
 
     return $action->getActionGroupKey();
   }
 
   public function isActionAvailable($action_key) {
     $action = $this->getActionImplementation($action_key);
 
     if (!$action) {
       return null;
     }
 
     return $action->isActionAvailable();
   }
 
   public function getActions($rule_type) {
     $actions = array();
     foreach ($this->getActionsForRuleType($rule_type) as $key => $action) {
       $actions[] = $key;
     }
 
     return $actions;
   }
 
   public function getActionNameMap($rule_type) {
     $map = array();
     foreach ($this->getActionsForRuleType($rule_type) as $key => $action) {
       $map[$key] = $action->getHeraldActionName();
     }
 
     return $map;
   }
 
   public function willSaveAction(
     HeraldRule $rule,
     HeraldActionRecord $action) {
 
     $impl = $this->requireActionImplementation($action->getAction());
     $target = $action->getTarget();
     $target = $impl->willSaveActionValue($target);
 
     $action->setTarget($target);
   }
 
 
 
 /* -(  Values  )------------------------------------------------------------- */
 
 
   public function getValueTypeForFieldAndCondition($field, $condition) {
     return $this->requireFieldImplementation($field)
       ->getHeraldFieldValueType($condition);
   }
 
   public function getValueTypeForAction($action, $rule_type) {
     $impl = $this->requireActionImplementation($action);
     return $impl->getHeraldActionValueType();
   }
 
 /* -(  Repetition  )--------------------------------------------------------- */
 
 
   public function getRepetitionOptions() {
     $options = array();
 
     $options[] = HeraldRule::REPEAT_EVERY;
 
     // Some rules, like pre-commit rules, only ever fire once. It doesn't
     // make sense to use state-based repetition policies like "only the first
     // time" for these rules.
 
     if (!$this->isSingleEventAdapter()) {
       $options[] = HeraldRule::REPEAT_FIRST;
       $options[] = HeraldRule::REPEAT_CHANGE;
     }
 
     return $options;
   }
 
   protected function initializeNewAdapter() {
     $this->setObject($this->newObject());
     return $this;
   }
 
   /**
    * Does this adapter's event fire only once?
    *
    * Single use adapters (like pre-commit and diff adapters) only fire once,
    * so fields like "Is new object" don't make sense to apply to their content.
    *
    * @return bool
    */
   public function isSingleEventAdapter() {
     return false;
   }
 
   public static function getAllAdapters() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getAdapterContentType')
       ->setSortMethod('getAdapterSortKey')
       ->execute();
   }
 
   public static function getAdapterForContentType($content_type) {
     $adapters = self::getAllAdapters();
 
     foreach ($adapters as $adapter) {
       if ($adapter->getAdapterContentType() == $content_type) {
         $adapter = id(clone $adapter);
         $adapter->initializeNewAdapter();
         return $adapter;
       }
     }
 
     throw new Exception(
       pht(
         'No adapter exists for Herald content type "%s".',
         $content_type));
   }
 
   public static function getEnabledAdapterMap(PhabricatorUser $viewer) {
     $map = array();
 
     $adapters = self::getAllAdapters();
     foreach ($adapters as $adapter) {
       if (!$adapter->isAvailableToUser($viewer)) {
         continue;
       }
       $type = $adapter->getAdapterContentType();
       $name = $adapter->getAdapterContentName();
       $map[$type] = $name;
     }
 
     return $map;
   }
 
   public function getEditorValueForCondition(
     PhabricatorUser $viewer,
     HeraldCondition $condition) {
 
     $field = $this->requireFieldImplementation($condition->getFieldName());
 
     return $field->getEditorValue(
       $viewer,
       $condition->getFieldCondition(),
       $condition->getValue());
   }
 
   public function getEditorValueForAction(
     PhabricatorUser $viewer,
     HeraldActionRecord $action_record) {
 
     $action = $this->requireActionImplementation($action_record->getAction());
 
     return $action->getEditorValue(
       $viewer,
       $action_record->getTarget());
   }
 
   public function renderRuleAsText(
     HeraldRule $rule,
     PhabricatorUser $viewer) {
 
     require_celerity_resource('herald-css');
 
     $icon = id(new PHUIIconView())
       ->setIcon('fa-chevron-circle-right lightgreytext')
       ->addClass('herald-list-icon');
 
     if ($rule->getMustMatchAll()) {
       $match_text = pht('When all of these conditions are met:');
     } else {
       $match_text = pht('When any of these conditions are met:');
     }
 
     $match_title = phutil_tag(
       'p',
       array(
         'class' => 'herald-list-description',
       ),
       $match_text);
 
     $match_list = array();
     foreach ($rule->getConditions() as $condition) {
       $match_list[] = phutil_tag(
         'div',
         array(
           'class' => 'herald-list-item',
         ),
         array(
           $icon,
           $this->renderConditionAsText($condition, $viewer),
         ));
     }
 
     if ($rule->isRepeatFirst()) {
       $action_text = pht(
         'Take these actions the first time this rule matches:');
     } else if ($rule->isRepeatOnChange()) {
       $action_text = pht(
         'Take these actions if this rule did not match the last time:');
     } else {
       $action_text = pht(
         'Take these actions every time this rule matches:');
     }
 
     $action_title = phutil_tag(
       'p',
       array(
         'class' => 'herald-list-description',
       ),
       $action_text);
 
     $action_list = array();
     foreach ($rule->getActions() as $action) {
       $action_list[] = phutil_tag(
         'div',
         array(
           'class' => 'herald-list-item',
         ),
         array(
           $icon,
           $this->renderActionAsText($viewer, $action),
         ));
     }
 
     return array(
       $match_title,
       $match_list,
       $action_title,
       $action_list,
     );
   }
 
   private function renderConditionAsText(
     HeraldCondition $condition,
     PhabricatorUser $viewer) {
 
     $field_type = $condition->getFieldName();
     $field = $this->getFieldImplementation($field_type);
 
     if (!$field) {
       return pht('Unknown Field: "%s"', $field_type);
     }
 
     $field_name = $field->getHeraldFieldName();
 
     $condition_type = $condition->getFieldCondition();
     $condition_name = idx($this->getConditionNameMap(), $condition_type);
 
     $value = $this->renderConditionValueAsText($condition, $viewer);
 
     return array(
       $field_name,
       ' ',
       $condition_name,
       ' ',
       $value,
     );
   }
 
   private function renderActionAsText(
     PhabricatorUser $viewer,
     HeraldActionRecord $action_record) {
 
     $action_type = $action_record->getAction();
     $action_value = $action_record->getTarget();
 
     $action = $this->getActionImplementation($action_type);
     if (!$action) {
       return pht('Unknown Action ("%s")', $action_type);
     }
 
     $action->setViewer($viewer);
 
     return $action->renderActionDescription($action_value);
   }
 
   private function renderConditionValueAsText(
     HeraldCondition $condition,
     PhabricatorUser $viewer) {
 
     $field = $this->requireFieldImplementation($condition->getFieldName());
 
     return $field->renderConditionValue(
       $viewer,
       $condition->getFieldCondition(),
       $condition->getValue());
   }
 
   public function renderFieldTranscriptValue(
     PhabricatorUser $viewer,
     $field_type,
     $field_value) {
 
     $field = $this->getFieldImplementation($field_type);
     if ($field) {
       return $field->renderTranscriptValue(
         $viewer,
         $field_value);
     }
 
     return phutil_tag(
       'em',
       array(),
       pht(
         'Unable to render value for unknown field type ("%s").',
         $field_type));
   }
 
 
 /* -(  Applying Effects  )--------------------------------------------------- */
 
 
   /**
    * @task apply
    */
   protected function applyStandardEffect(HeraldEffect $effect) {
     $action = $effect->getAction();
     $rule_type = $effect->getRule()->getRuleType();
 
     $impl = $this->getActionImplementation($action);
     if (!$impl) {
       return new HeraldApplyTranscript(
         $effect,
         false,
         array(
           array(
             HeraldAction::DO_STANDARD_INVALID_ACTION,
             $action,
           ),
         ));
     }
 
     if (!$impl->supportsRuleType($rule_type)) {
       return new HeraldApplyTranscript(
         $effect,
         false,
         array(
           array(
             HeraldAction::DO_STANDARD_WRONG_RULE_TYPE,
             $rule_type,
           ),
         ));
     }
 
     $impl->applyEffect($this->getObject(), $effect);
     return $impl->getApplyTranscript($effect);
   }
 
   public function loadEdgePHIDs($type) {
     if (!isset($this->edgeCache[$type])) {
       $phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
         $this->getObject()->getPHID(),
         $type);
 
       $this->edgeCache[$type] = array_fuse($phids);
     }
     return $this->edgeCache[$type];
   }
 
 
 /* -(  Forbidden Actions  )-------------------------------------------------- */
 
 
   final public function getForbiddenActions() {
     return array_keys($this->forbiddenActions);
   }
 
   final public function setForbiddenAction($action, $reason) {
     $this->forbiddenActions[$action] = $reason;
     return $this;
   }
 
   final public function getRequiredFieldStates($field_key) {
     return $this->requireFieldImplementation($field_key)
       ->getRequiredAdapterStates();
   }
 
   final public function getRequiredActionStates($action_key) {
     return $this->requireActionImplementation($action_key)
       ->getRequiredAdapterStates();
   }
 
   final public function getForbiddenReason($action) {
     if (!isset($this->forbiddenActions[$action])) {
       throw new Exception(
         pht(
           'Action "%s" is not forbidden!',
           $action));
     }
 
     return $this->forbiddenActions[$action];
   }
 
 
 /* -(  Must Encrypt  )------------------------------------------------------- */
 
 
   final public function addMustEncryptReason($reason) {
     $this->mustEncryptReasons[] = $reason;
     return $this;
   }
 
   final public function getMustEncryptReasons() {
     return $this->mustEncryptReasons;
   }
 
 
 /* -(  Webhooks  )----------------------------------------------------------- */
 
 
   public function supportsWebhooks() {
     return true;
   }
 
 
   final public function queueWebhook($webhook_phid, $rule_phid) {
     $this->webhookMap[$webhook_phid][] = $rule_phid;
     return $this;
   }
 
   final public function getWebhookMap() {
     return $this->webhookMap;
   }
 
 }
diff --git a/src/applications/maniphest/constants/ManiphestTaskPriority.php b/src/applications/maniphest/constants/ManiphestTaskPriority.php
index 8b43da132b..1b20b52738 100644
--- a/src/applications/maniphest/constants/ManiphestTaskPriority.php
+++ b/src/applications/maniphest/constants/ManiphestTaskPriority.php
@@ -1,261 +1,261 @@
 <?php
 
 final class ManiphestTaskPriority extends ManiphestConstants {
 
   const UNKNOWN_PRIORITY_KEYWORD = '!!unknown!!';
 
   /**
    * Get the priorities and their full descriptions.
    *
    * @return  map Priorities to descriptions.
    */
   public static function getTaskPriorityMap() {
     $map = self::getConfig();
     foreach ($map as $key => $spec) {
       $map[$key] = idx($spec, 'name', $key);
     }
     return $map;
   }
 
 
   /**
    * Get the priorities and their command keywords.
    *
    * @return map Priorities to lists of command keywords.
    */
   public static function getTaskPriorityKeywordsMap() {
     $map = self::getConfig();
     foreach ($map as $key => $spec) {
       $words = idx($spec, 'keywords', array());
       if (!is_array($words)) {
         $words = array($words);
       }
 
       foreach ($words as $word_key => $word) {
         $words[$word_key] = phutil_utf8_strtolower($word);
       }
 
       $words = array_unique($words);
 
       $map[$key] = $words;
     }
 
     return $map;
   }
 
   /**
    * Get the canonical keyword for a given priority constant.
    *
    * @return string|null Keyword, or `null` if no keyword is configured.
    */
   public static function getKeywordForTaskPriority($priority) {
     $map = self::getConfig();
 
     $spec = idx($map, $priority);
     if (!$spec) {
       return null;
     }
 
     $keywords = idx($spec, 'keywords');
     if (!$keywords) {
       return null;
     }
 
     return head($keywords);
   }
 
 
   /**
    * Get a map of supported alternate names for each priority.
    *
    * Keys are aliases, like "wish" and "wishlist". Values are canonical
    * priority keywords, like "wishlist".
    *
    * @return map<string, string> Map of aliases to canonical priority keywords.
    */
   public static function getTaskPriorityAliasMap() {
     $keyword_map = self::getTaskPriorityKeywordsMap();
 
     $result = array();
     foreach ($keyword_map as $key => $keywords) {
       $target = self::getKeywordForTaskPriority($key);
       if ($target === null) {
         continue;
       }
 
       // NOTE: Include the raw priority value, like "25", in the list of
       // aliases. This supports legacy sources like saved EditEngine forms.
       $result[$key] = $target;
 
       foreach ($keywords as $keyword) {
         $result[$keyword] = $target;
       }
     }
 
     return $result;
   }
 
 
   /**
    * Get the priorities and their related short (one-word) descriptions.
    *
    * @return  map Priorities to short descriptions.
    */
   public static function getShortNameMap() {
     $map = self::getConfig();
     foreach ($map as $key => $spec) {
       $map[$key] = idx($spec, 'short', idx($spec, 'name', $key));
     }
     return $map;
   }
 
 
   /**
    * Get a map from priority constants to their colors.
    *
    * @return map<int, string> Priorities to colors.
    */
   public static function getColorMap() {
     $map = self::getConfig();
     foreach ($map as $key => $spec) {
       $map[$key] = idx($spec, 'color', 'grey');
     }
     return $map;
   }
 
 
   /**
    * Return the default priority for this instance of Phabricator.
    *
    * @return int The value of the default priority constant.
    */
   public static function getDefaultPriority() {
     return PhabricatorEnv::getEnvConfig('maniphest.default-priority');
   }
 
 
   /**
    * Retrieve the full name of the priority level provided.
    *
-   * @param   int     A priority level.
+   * @param   int     $priority A priority level.
    * @return  string  The priority name if the level is a valid one.
    */
   public static function getTaskPriorityName($priority) {
     return idx(self::getTaskPriorityMap(), $priority, $priority);
   }
 
   /**
    * Retrieve the color of the priority level given
    *
-   * @param   int     A priority level.
+   * @param   int     $priority A priority level.
    * @return  string  The color of the priority if the level is valid,
    *                  or black if it is not.
    */
   public static function getTaskPriorityColor($priority) {
     return idx(self::getColorMap(), $priority, 'black');
   }
 
   public static function getTaskPriorityIcon($priority) {
     return 'fa-arrow-right';
   }
 
   public static function getTaskPriorityFromKeyword($keyword) {
     $map = self::getTaskPriorityKeywordsMap();
 
     foreach ($map as $priority => $keywords) {
       if (in_array($keyword, $keywords)) {
         return $priority;
       }
     }
 
     return null;
   }
 
   public static function isDisabledPriority($priority) {
     $config = idx(self::getConfig(), $priority, array());
     return idx($config, 'disabled', false);
   }
 
   public static function getConfig() {
     $config = PhabricatorEnv::getEnvConfig('maniphest.priorities');
     krsort($config);
     return $config;
   }
 
   private static function isValidPriorityKeyword($keyword) {
     if (!strlen($keyword) || strlen($keyword) > 64) {
       return false;
     }
 
     // Alphanumeric, but not exclusively numeric
     if (!preg_match('/^(?![0-9]*$)[a-zA-Z0-9]+$/', $keyword)) {
       return false;
     }
     return true;
   }
 
   public static function validateConfiguration($config) {
     if (!is_array($config)) {
       throw new Exception(
         pht(
           'Configuration is not valid. Maniphest priority configurations '.
           'must be dictionaries.'));
     }
 
     $all_keywords = array();
     foreach ($config as $key => $value) {
       if (!ctype_digit((string)$key)) {
         throw new Exception(
           pht(
             'Key "%s" is not a valid priority constant. Priority constants '.
             'must be nonnegative integers.',
             $key));
       }
 
       if (!is_array($value)) {
         throw new Exception(
           pht(
             'Value for key "%s" should be a dictionary.',
             $key));
       }
 
       PhutilTypeSpec::checkMap(
         $value,
         array(
           'name' => 'string',
           'keywords' => 'list<string>',
           'short' => 'optional string',
           'color' => 'optional string',
           'disabled' => 'optional bool',
         ));
 
       $keywords = $value['keywords'];
       foreach ($keywords as $keyword) {
         if (!self::isValidPriorityKeyword($keyword)) {
           throw new Exception(
             pht(
               'Key "%s" is not a valid priority keyword. Priority keywords '.
               'must be 1-64 alphanumeric characters and cannot be '.
               'exclusively digits. For example, "%s" or "%s" are '.
               'reasonable choices.',
               $keyword,
               'low',
               'critical'));
         }
 
         if (isset($all_keywords[$keyword])) {
           throw new Exception(
             pht(
               'Two different task priorities ("%s" and "%s") have the same '.
               'keyword ("%s"). Keywords must uniquely identify priorities.',
               $value['name'],
               $all_keywords[$keyword],
               $keyword));
         }
 
         $all_keywords[$keyword] = $value['name'];
       }
     }
   }
 
 }
diff --git a/src/applications/meta/panel/PhabricatorApplicationConfigurationPanel.php b/src/applications/meta/panel/PhabricatorApplicationConfigurationPanel.php
index d96132c9b0..944daa9e08 100644
--- a/src/applications/meta/panel/PhabricatorApplicationConfigurationPanel.php
+++ b/src/applications/meta/panel/PhabricatorApplicationConfigurationPanel.php
@@ -1,77 +1,77 @@
 <?php
 
 abstract class PhabricatorApplicationConfigurationPanel
   extends Phobject {
 
   private $viewer;
   private $application;
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   public function setApplication(PhabricatorApplication $application) {
     $this->application = $application;
     return $this;
   }
 
   public function getApplication() {
     return $this->application;
   }
 
   /**
    * Get the URI for this application configuration panel.
    *
-   * @param string? Optional path to append.
+   * @param string? $path Optional path to append.
    * @return string Relative URI for the panel.
    */
   public function getPanelURI($path = '') {
     $app_key = get_class($this->getApplication());
     $panel_key = $this->getPanelKey();
     $base = "/applications/panel/{$app_key}/{$panel_key}/";
     return $base.ltrim($path, '/');
   }
 
   /**
    * Return a short, unique string key which identifies this panel.
    *
    * This key is used in URIs. Good values might be "email" or "files".
    */
   abstract public function getPanelKey();
 
   abstract public function shouldShowForApplication(
     PhabricatorApplication $application);
 
   abstract public function buildConfigurationPagePanel();
   abstract public function handlePanelRequest(
     AphrontRequest $request,
     PhabricatorController $controller);
 
   public static function loadAllPanels() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getPanelKey')
       ->execute();
   }
 
   public static function loadAllPanelsForApplication(
     PhabricatorApplication $application) {
     $panels = self::loadAllPanels();
 
     $application_panels = array();
     foreach ($panels as $key => $panel) {
       if (!$panel->shouldShowForApplication($application)) {
         continue;
       }
       $application_panels[$key] = $panel;
     }
 
     return $application_panels;
   }
 
 }
diff --git a/src/applications/metamta/parser/PhabricatorMetaMTAEmailBodyParser.php b/src/applications/metamta/parser/PhabricatorMetaMTAEmailBodyParser.php
index f20e626573..45b7735ce0 100644
--- a/src/applications/metamta/parser/PhabricatorMetaMTAEmailBodyParser.php
+++ b/src/applications/metamta/parser/PhabricatorMetaMTAEmailBodyParser.php
@@ -1,168 +1,168 @@
 <?php
 
 final class PhabricatorMetaMTAEmailBodyParser extends Phobject {
 
   /**
    * Mails can have bodies such as
    *
    *   !claim
    *
    *   taking this task
    *
    * Or
    *
    *   !assign alincoln
    *
    *   please, take this task I took; its hard
    *
    * This function parses such an email body and returns a dictionary
    * containing a clean body text (e.g. "taking this task"), and a list of
    * commands. For example, this body above might parse as:
    *
    *   array(
    *     'body' => 'please, take this task I took; it's hard',
    *     'commands' => array(
    *       array('assign', 'alincoln'),
    *     ),
    *   )
    *
-   * @param   string  Raw mail text body.
+   * @param   string  $body Raw mail text body.
    * @return  dict    Parsed body.
    */
   public function parseBody($body) {
     $body = $this->stripTextBody($body);
 
     $commands = array();
 
     $lines = phutil_split_lines($body, $retain_endings = true);
 
     // We'll match commands at the beginning and end of the mail, but not
     // in the middle of the mail body.
     list($top_commands, $lines) = $this->stripCommands($lines);
     list($end_commands, $lines) = $this->stripCommands(array_reverse($lines));
     $lines = array_reverse($lines);
     $commands = array_merge($top_commands, array_reverse($end_commands));
 
     $lines = rtrim(implode('', $lines));
 
     return array(
       'body' => $lines,
       'commands' => $commands,
     );
   }
 
   private function stripCommands(array $lines) {
     $saw_command = false;
     $commands = array();
     foreach ($lines as $key => $line) {
       if (!strlen(trim($line)) && $saw_command) {
         unset($lines[$key]);
         continue;
       }
 
       $matches = null;
       if (!preg_match('/^\s*!(\w+.*$)/', $line, $matches)) {
         break;
       }
 
       $arg_str = $matches[1];
       $argv = preg_split('/[,\s]+/', trim($arg_str));
       $commands[] = $argv;
       unset($lines[$key]);
 
       $saw_command = true;
     }
 
     return array($commands, $lines);
   }
 
   public function stripTextBody($body) {
     return trim($this->stripSignature($this->stripQuotedText($body)));
   }
 
   private function stripQuotedText($body) {
     $body = phutil_string_cast($body);
 
     // Look for "On <date>, <user> wrote:". This may be split across multiple
     // lines. We need to be careful not to remove all of a message like this:
     //
     //   On which day do you want to meet?
     //
     //   On <date>, <user> wrote:
     //   > Let's set up a meeting.
 
     $start = null;
     $lines = phutil_split_lines($body);
     foreach ($lines as $key => $line) {
       if (preg_match('/^\s*>?\s*On\b/', $line)) {
         $start = $key;
       }
       if ($start !== null) {
         if (preg_match('/\bwrote:/', $line)) {
           $lines = array_slice($lines, 0, $start);
           $body = implode('', $lines);
           break;
         }
       }
     }
 
     // Outlook english
     $body = preg_replace(
       '/^\s*(> )?-----Original Message-----.*?/imsU',
       '',
       $body);
 
     // Outlook danish
     $body = preg_replace(
       '/^\s*(> )?-----Oprindelig Meddelelse-----.*?/imsU',
       '',
       $body);
 
     // See example in T3217.
     $body = preg_replace(
       '/^________________________________________\s+From:.*?/imsU',
       '',
       $body);
 
     // French GMail quoted text. See T8199.
     $body = preg_replace(
       '/^\s*\d{4}-\d{2}-\d{2} \d+:\d+ GMT.*:.*?/imsU',
       '',
       $body);
 
     return rtrim($body);
   }
 
   private function stripSignature($body) {
     // Quasi-"standard" delimiter, for lols see:
     //   https://bugzilla.mozilla.org/show_bug.cgi?id=58406
     $body = preg_replace(
       '/^-- +$.*/sm',
       '',
       $body);
 
     // Mailbox seems to make an attempt to comply with the "standard" but
     // omits the leading newline and uses an em dash. This may or may not have
     // the trailing space, but it's unique enough that there's no real ambiguity
     // in detecting it.
     $body = preg_replace(
       "/\s*\xE2\x80\x94\s*\nSent from Mailbox\s*\z/su",
       '',
       $body);
 
     // HTC Mail application (mobile)
     $body = preg_replace(
       '/^\s*^Sent from my HTC smartphone.*/sm',
       '',
       $body);
 
     // Apple iPhone
     $body = preg_replace(
       '/^\s*^Sent from my iPhone\s*$.*/sm',
       '',
       $body);
 
     return rtrim($body);
   }
 
 }
diff --git a/src/applications/metamta/receiver/PhabricatorObjectMailReceiver.php b/src/applications/metamta/receiver/PhabricatorObjectMailReceiver.php
index 65c6089f3b..4b7b0eee37 100644
--- a/src/applications/metamta/receiver/PhabricatorObjectMailReceiver.php
+++ b/src/applications/metamta/receiver/PhabricatorObjectMailReceiver.php
@@ -1,191 +1,191 @@
 <?php
 
 abstract class PhabricatorObjectMailReceiver extends PhabricatorMailReceiver {
 
   /**
    * Return a regular expression fragment which matches the name of an
    * object which can receive mail. For example, Differential uses:
    *
    *  D[1-9]\d*
    *
    * ...to match `D123`, etc., identifying Differential Revisions.
    *
    * @return string Regular expression fragment.
    */
   abstract protected function getObjectPattern();
 
 
   /**
    * Load the object receiving mail, based on an identifying pattern. Normally
    * this pattern is some sort of object ID.
    *
-   * @param   string          A string matched by @{method:getObjectPattern}
-   *                          fragment.
-   * @param   PhabricatorUser The viewing user.
+   * @param   string          $pattern A string matched by
+   *                          @{method:getObjectPattern} fragment.
+   * @param   PhabricatorUser $viewer The viewing user.
    * @return  object|null     The object to receive mail, or null if no such
    *                          object exists.
    */
   abstract protected function loadObject($pattern, PhabricatorUser $viewer);
 
 
   final protected function processReceivedMail(
     PhabricatorMetaMTAReceivedMail $mail,
     PhutilEmailAddress $target) {
 
     $parts = $this->matchObjectAddress($target);
     if (!$parts) {
       // We should only make it here if we matched already in "canAcceptMail()",
       // so this is a surprise.
       throw new Exception(
         pht(
           'Failed to parse object address ("%s") during processing.',
           (string)$target));
     }
 
     $pattern = $parts['pattern'];
     $sender = $this->getSender();
 
     try {
       $object = $this->loadObject($pattern, $sender);
     } catch (PhabricatorPolicyException $policy_exception) {
       throw new PhabricatorMetaMTAReceivedMailProcessingException(
         MetaMTAReceivedMailStatus::STATUS_POLICY_PROBLEM,
         pht(
           'This mail is addressed to an object ("%s") you do not have '.
           'permission to see: %s',
           $pattern,
           $policy_exception->getMessage()));
     }
 
     if (!$object) {
       throw new PhabricatorMetaMTAReceivedMailProcessingException(
         MetaMTAReceivedMailStatus::STATUS_NO_SUCH_OBJECT,
         pht(
           'This mail is addressed to an object ("%s"), but that object '.
           'does not exist.',
           $pattern));
     }
 
     $sender_identifier = $parts['sender'];
     if ($sender_identifier === 'public') {
       if (!PhabricatorEnv::getEnvConfig('metamta.public-replies')) {
         throw new PhabricatorMetaMTAReceivedMailProcessingException(
           MetaMTAReceivedMailStatus::STATUS_NO_PUBLIC_MAIL,
           pht(
             'This mail is addressed to the public email address of an object '.
             '("%s"), but public replies are not enabled on this server. An '.
             'administrator may have recently disabled this setting, or you '.
             'may have replied to an old message. Try replying to a more '.
             'recent message instead.',
             $pattern));
       }
       $check_phid = $object->getPHID();
     } else {
       if ($sender_identifier != $sender->getID()) {
         throw new PhabricatorMetaMTAReceivedMailProcessingException(
           MetaMTAReceivedMailStatus::STATUS_USER_MISMATCH,
           pht(
             'This mail is addressed to the private email address of an object '.
             '("%s"), but you are not the user who is authorized to use the '.
             'address you sent mail to. Each private address is unique to the '.
             'user who received the original mail. Try replying to a message '.
             'which was sent directly to you instead.',
             $pattern));
       }
       $check_phid = $sender->getPHID();
     }
 
     $mail_key = PhabricatorMetaMTAMailProperties::loadMailKey($object);
     $expect_hash = self::computeMailHash($mail_key, $check_phid);
 
     if (!phutil_hashes_are_identical($expect_hash, $parts['hash'])) {
       throw new PhabricatorMetaMTAReceivedMailProcessingException(
         MetaMTAReceivedMailStatus::STATUS_HASH_MISMATCH,
         pht(
           'This mail is addressed to an object ("%s"), but the address is '.
           'not correct (the security hash is wrong). Check that the address '.
           'is correct.',
           $pattern));
     }
 
     $mail->setRelatedPHID($object->getPHID());
     $this->processReceivedObjectMail($mail, $object, $sender);
 
     return $this;
   }
 
   protected function processReceivedObjectMail(
     PhabricatorMetaMTAReceivedMail $mail,
     PhabricatorLiskDAO $object,
     PhabricatorUser $sender) {
 
     $handler = $this->getTransactionReplyHandler();
     if ($handler) {
       return $handler
         ->setMailReceiver($object)
         ->setActor($sender)
         ->setExcludeMailRecipientPHIDs($mail->loadAllRecipientPHIDs())
         ->processEmail($mail);
     }
 
     throw new PhutilMethodNotImplementedException();
   }
 
   protected function getTransactionReplyHandler() {
     return null;
   }
 
   public function loadMailReceiverObject($pattern, PhabricatorUser $viewer) {
     return $this->loadObject($pattern, $viewer);
   }
 
   final public function canAcceptMail(
     PhabricatorMetaMTAReceivedMail $mail,
     PhutilEmailAddress $target) {
 
     // If we don't have a valid sender user account, we can never accept
     // mail to any object.
     $sender = $this->getSender();
     if (!$sender) {
       return false;
     }
 
     return (bool)$this->matchObjectAddress($target);
   }
 
   private function matchObjectAddress(PhutilEmailAddress $address) {
     $address = PhabricatorMailUtil::normalizeAddress($address);
     $local = $address->getLocalPart();
 
     $regexp = $this->getAddressRegexp();
     $matches = null;
     if (!preg_match($regexp, $local, $matches)) {
       return false;
     }
 
     return $matches;
   }
 
   private function getAddressRegexp() {
     $pattern = $this->getObjectPattern();
 
     $regexp =
       '(^'.
         '(?P<pattern>'.$pattern.')'.
         '\\+'.
         '(?P<sender>\w+)'.
         '\\+'.
         '(?P<hash>[a-f0-9]{16})'.
       '$)Ui';
 
     return $regexp;
   }
 
   public static function computeMailHash($mail_key, $phid) {
     $hash = PhabricatorHash::digestWithNamedKey(
       $mail_key.$phid,
       'mail.object-address-key');
     return substr($hash, 0, 16);
   }
 
 }
diff --git a/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php b/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php
index 72e23ec2c1..53941e94cf 100644
--- a/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php
+++ b/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php
@@ -1,411 +1,411 @@
 <?php
 
 abstract class PhabricatorMailReplyHandler extends Phobject {
 
   private $mailReceiver;
   private $applicationEmail;
   private $actor;
   private $excludePHIDs = array();
   private $unexpandablePHIDs = array();
 
   final public function setMailReceiver($mail_receiver) {
     $this->validateMailReceiver($mail_receiver);
     $this->mailReceiver = $mail_receiver;
     return $this;
   }
 
   final public function getMailReceiver() {
     return $this->mailReceiver;
   }
 
   public function setApplicationEmail(
     PhabricatorMetaMTAApplicationEmail $email) {
     $this->applicationEmail = $email;
     return $this;
   }
 
   public function getApplicationEmail() {
     return $this->applicationEmail;
   }
 
   final public function setActor(PhabricatorUser $actor) {
     $this->actor = $actor;
     return $this;
   }
 
   final public function getActor() {
     return $this->actor;
   }
 
   final public function setExcludeMailRecipientPHIDs(array $exclude) {
     $this->excludePHIDs = $exclude;
     return $this;
   }
 
   final public function getExcludeMailRecipientPHIDs() {
     return $this->excludePHIDs;
   }
 
   public function setUnexpandablePHIDs(array $phids) {
     $this->unexpandablePHIDs = $phids;
     return $this;
   }
 
   public function getUnexpandablePHIDs() {
     return $this->unexpandablePHIDs;
   }
 
   abstract public function validateMailReceiver($mail_receiver);
   abstract public function getPrivateReplyHandlerEmailAddress(
     PhabricatorUser $user);
 
   public function getReplyHandlerDomain() {
     return PhabricatorEnv::getEnvConfig('metamta.reply-handler-domain');
   }
 
   abstract protected function receiveEmail(
     PhabricatorMetaMTAReceivedMail $mail);
 
   public function processEmail(PhabricatorMetaMTAReceivedMail $mail) {
     return $this->receiveEmail($mail);
   }
 
   public function supportsPrivateReplies() {
     return (bool)$this->getReplyHandlerDomain() &&
            !$this->supportsPublicReplies();
   }
 
   public function supportsPublicReplies() {
     if (!PhabricatorEnv::getEnvConfig('metamta.public-replies')) {
       return false;
     }
 
     if (!$this->getReplyHandlerDomain()) {
       return false;
     }
 
     return (bool)$this->getPublicReplyHandlerEmailAddress();
   }
 
   final public function supportsReplies() {
     return $this->supportsPrivateReplies() ||
            $this->supportsPublicReplies();
   }
 
   public function getPublicReplyHandlerEmailAddress() {
     return null;
   }
 
   protected function getDefaultPublicReplyHandlerEmailAddress($prefix) {
 
     $receiver = $this->getMailReceiver();
     $receiver_id = $receiver->getID();
     $domain = $this->getReplyHandlerDomain();
 
     // We compute a hash using the object's own PHID to prevent an attacker
     // from blindly interacting with objects that they haven't ever received
     // mail about by just sending to D1@, D2@, etc...
 
     $mail_key = PhabricatorMetaMTAMailProperties::loadMailKey($receiver);
 
     $hash = PhabricatorObjectMailReceiver::computeMailHash(
       $mail_key,
       $receiver->getPHID());
 
     $address = "{$prefix}{$receiver_id}+public+{$hash}@{$domain}";
     return $this->getSingleReplyHandlerPrefix($address);
   }
 
   protected function getSingleReplyHandlerPrefix($address) {
     $single_handle_prefix = PhabricatorEnv::getEnvConfig(
       'metamta.single-reply-handler-prefix');
     return ($single_handle_prefix)
       ? $single_handle_prefix.'+'.$address
       : $address;
   }
 
   protected function getDefaultPrivateReplyHandlerEmailAddress(
     PhabricatorUser $user,
     $prefix) {
 
     $receiver = $this->getMailReceiver();
     $receiver_id = $receiver->getID();
     $user_id = $user->getID();
 
     $mail_key = PhabricatorMetaMTAMailProperties::loadMailKey($receiver);
 
     $hash = PhabricatorObjectMailReceiver::computeMailHash(
       $mail_key,
       $user->getPHID());
     $domain = $this->getReplyHandlerDomain();
 
     $address = "{$prefix}{$receiver_id}+{$user_id}+{$hash}@{$domain}";
     return $this->getSingleReplyHandlerPrefix($address);
   }
 
   final protected function enhanceBodyWithAttachments(
     $body,
     array $attachments) {
 
     if (!$attachments) {
       return $body;
     }
 
     $files = id(new PhabricatorFileQuery())
       ->setViewer($this->getActor())
       ->withPHIDs($attachments)
       ->execute();
 
     $output = array();
     $output[] = $body;
 
     // We're going to put all the non-images first in a list, then embed
     // the images.
     $head = array();
     $tail = array();
     foreach ($files as $file) {
       if ($file->isViewableImage()) {
         $tail[] = $file;
       } else {
         $head[] = $file;
       }
     }
 
     if ($head) {
       $list = array();
       foreach ($head as $file) {
         $list[] = '  - {'.$file->getMonogram().', layout=link}';
       }
       $output[] = implode("\n", $list);
     }
 
     if ($tail) {
       $list = array();
       foreach ($tail as $file) {
         $list[] = '{'.$file->getMonogram().'}';
       }
       $output[] = implode("\n\n", $list);
     }
 
     $output = implode("\n\n", $output);
 
     return rtrim($output);
   }
 
 
   /**
    * Produce a list of mail targets for a given to/cc list.
    *
    * Each target should be sent a separate email, and contains the information
    * required to generate it with appropriate permissions and configuration.
    *
-   * @param list<phid> List of "To" PHIDs.
-   * @param list<phid> List of "CC" PHIDs.
+   * @param list<phid> $raw_to List of "To" PHIDs.
+   * @param list<phid> $raw_cc List of "CC" PHIDs.
    * @return list<PhabricatorMailTarget> List of targets.
    */
   final public function getMailTargets(array $raw_to, array $raw_cc) {
     list($to, $cc) = $this->expandRecipientPHIDs($raw_to, $raw_cc);
     list($to, $cc) = $this->loadRecipientUsers($to, $cc);
     list($to, $cc) = $this->filterRecipientUsers($to, $cc);
 
     if (!$to && !$cc) {
       return array();
     }
 
     $template = id(new PhabricatorMailTarget())
       ->setRawToPHIDs($raw_to)
       ->setRawCCPHIDs($raw_cc);
 
     // Set the public reply address as the default, if one exists. We
     // might replace this with a private address later.
     if ($this->supportsPublicReplies()) {
       $reply_to = $this->getPublicReplyHandlerEmailAddress();
       if ($reply_to) {
         $template->setReplyTo($reply_to);
       }
     }
 
     $supports_private_replies = $this->supportsPrivateReplies();
     $mail_all = !PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient');
     $targets = array();
     if ($mail_all) {
       $target = id(clone $template)
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->setToMap($to)
         ->setCCMap($cc);
 
       $targets[] = $target;
     } else {
       $map = $to + $cc;
 
       foreach ($map as $phid => $user) {
         // Preserve the original To/Cc information on the target.
         if (isset($to[$phid])) {
           $target_to = array($phid => $user);
           $target_cc = array();
         } else {
           $target_to = array();
           $target_cc = array($phid => $user);
         }
 
         $target = id(clone $template)
           ->setViewer($user)
           ->setToMap($target_to)
           ->setCCMap($target_cc);
 
         if ($supports_private_replies) {
           $reply_to = $this->getPrivateReplyHandlerEmailAddress($user);
           if ($reply_to) {
             $target->setReplyTo($reply_to);
           }
         }
 
         $targets[] = $target;
       }
     }
 
     return $targets;
   }
 
 
   /**
    * Expand lists of recipient PHIDs.
    *
    * This takes any compound recipients (like projects) and looks up all their
    * members.
    *
-   * @param list<phid> List of To PHIDs.
-   * @param list<phid> List of CC PHIDs.
+   * @param list<phid> $to List of To PHIDs.
+   * @param list<phid> $cc List of CC PHIDs.
    * @return pair<list<phid>, list<phid>> Expanded PHID lists.
    */
   private function expandRecipientPHIDs(array $to, array $cc) {
     $to_result = array();
     $cc_result = array();
 
     // "Unexpandable" users have disengaged from an object (for example,
     // by resigning from a revision).
 
     // If such a user is still a direct recipient (for example, they're still
     // on the Subscribers list) they're fair game, but group targets (like
     // projects) will no longer include them when expanded.
 
     $unexpandable = $this->getUnexpandablePHIDs();
     $unexpandable = array_fuse($unexpandable);
 
     $all_phids = array_merge($to, $cc);
     if ($all_phids) {
       $map = id(new PhabricatorMetaMTAMemberQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withPHIDs($all_phids)
         ->execute();
       foreach ($to as $phid) {
         foreach ($map[$phid] as $expanded) {
           if ($expanded !== $phid) {
             if (isset($unexpandable[$expanded])) {
               continue;
             }
           }
           $to_result[$expanded] = $expanded;
         }
       }
       foreach ($cc as $phid) {
         foreach ($map[$phid] as $expanded) {
           if ($expanded !== $phid) {
             if (isset($unexpandable[$expanded])) {
               continue;
             }
           }
           $cc_result[$expanded] = $expanded;
         }
       }
     }
 
     // Remove recipients from "CC" if they're also present in "To".
     $cc_result = array_diff_key($cc_result, $to_result);
 
     return array(array_values($to_result), array_values($cc_result));
   }
 
 
   /**
    * Load @{class:PhabricatorUser} objects for each recipient.
    *
    * Invalid recipients are dropped from the results.
    *
-   * @param list<phid> List of To PHIDs.
-   * @param list<phid> List of CC PHIDs.
+   * @param list<phid> $to List of To PHIDs.
+   * @param list<phid> $cc List of CC PHIDs.
    * @return pair<wild, wild> Maps from PHIDs to users.
    */
   private function loadRecipientUsers(array $to, array $cc) {
     $to_result = array();
     $cc_result = array();
 
     $all_phids = array_merge($to, $cc);
     if ($all_phids) {
       // We need user settings here because we'll check translations later
       // when generating mail.
       $users = id(new PhabricatorPeopleQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withPHIDs($all_phids)
         ->needUserSettings(true)
         ->execute();
       $users = mpull($users, null, 'getPHID');
 
       foreach ($to as $phid) {
         if (isset($users[$phid])) {
           $to_result[$phid] = $users[$phid];
         }
       }
       foreach ($cc as $phid) {
         if (isset($users[$phid])) {
           $cc_result[$phid] = $users[$phid];
         }
       }
     }
 
     return array($to_result, $cc_result);
   }
 
 
   /**
    * Remove recipients who do not have permission to view the mail receiver.
    *
-   * @param map<string, PhabricatorUser> Map of "To" users.
-   * @param map<string, PhabricatorUser> Map of "CC" users.
+   * @param map<string, PhabricatorUser> $to Map of "To" users.
+   * @param map<string, PhabricatorUser> $cc Map of "CC" users.
    * @return pair<wild, wild> Filtered user maps.
    */
   private function filterRecipientUsers(array $to, array $cc) {
     $to_result = array();
     $cc_result = array();
 
     $all_users = $to + $cc;
     if ($all_users) {
       $can_see = array();
       $object = $this->getMailReceiver();
       foreach ($all_users as $phid => $user) {
         $visible = PhabricatorPolicyFilter::hasCapability(
           $user,
           $object,
           PhabricatorPolicyCapability::CAN_VIEW);
         if ($visible) {
           $can_see[$phid] = true;
         }
       }
 
       foreach ($to as $phid => $user) {
         if (!empty($can_see[$phid])) {
           $to_result[$phid] = $all_users[$phid];
         }
       }
 
       foreach ($cc as $phid => $user) {
         if (!empty($can_see[$phid])) {
           $cc_result[$phid] = $all_users[$phid];
         }
       }
     }
 
     return array($to_result, $cc_result);
   }
 
 }
diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php
index cc3ae82bef..2d1ec54bb9 100644
--- a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php
+++ b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php
@@ -1,1265 +1,1266 @@
 <?php
 
 /**
  * @task recipients   Managing Recipients
  */
 final class PhabricatorMetaMTAMail
   extends PhabricatorMetaMTADAO
   implements
     PhabricatorPolicyInterface,
     PhabricatorDestructibleInterface {
 
   const RETRY_DELAY   = 5;
 
   protected $actorPHID;
   protected $parameters = array();
   protected $status;
   protected $message;
   protected $relatedPHID;
 
   private $recipientExpansionMap;
   private $routingMap;
 
   public function __construct() {
 
     $this->status     = PhabricatorMailOutboundStatus::STATUS_QUEUE;
     $this->parameters = array(
       'sensitive' => true,
       'mustEncrypt' => false,
     );
 
     parent::__construct();
   }
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_SERIALIZATION => array(
         'parameters'  => self::SERIALIZATION_JSON,
       ),
       self::CONFIG_COLUMN_SCHEMA => array(
         'actorPHID' => 'phid?',
         'status' => 'text32',
         'relatedPHID' => 'phid?',
 
         // T6203/NULLABILITY
         // This should just be empty if there's no body.
         'message' => 'text?',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'status' => array(
           'columns' => array('status'),
         ),
         'key_actorPHID' => array(
           'columns' => array('actorPHID'),
         ),
         'relatedPHID' => array(
           'columns' => array('relatedPHID'),
         ),
         'key_created' => array(
           'columns' => array('dateCreated'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function generatePHID() {
     return PhabricatorPHID::generateNewPHID(
       PhabricatorMetaMTAMailPHIDType::TYPECONST);
   }
 
   protected function setParam($param, $value) {
     $this->parameters[$param] = $value;
     return $this;
   }
 
   protected function getParam($param, $default = null) {
     // Some old mail was saved without parameters because no parameters were
     // set or encoding failed. Recover in these cases so we can perform
     // mail migrations, see T9251.
     if (!is_array($this->parameters)) {
       $this->parameters = array();
     }
 
     return idx($this->parameters, $param, $default);
   }
 
   /**
    * These tags are used to allow users to opt out of receiving certain types
    * of mail, like updates when a task's projects change.
    *
-   * @param list<const>
+   * @param list<const> $tags
    * @return this
    */
   public function setMailTags(array $tags) {
     $this->setParam('mailtags', array_unique($tags));
     return $this;
   }
 
   public function getMailTags() {
     return $this->getParam('mailtags', array());
   }
 
   /**
    * In Gmail, conversations will be broken if you reply to a thread and the
    * server sends back a response without referencing your Message-ID, even if
    * it references a Message-ID earlier in the thread. To avoid this, use the
    * parent email's message ID explicitly if it's available. This overwrites the
    * "In-Reply-To" and "References" headers we would otherwise generate. This
    * needs to be set whenever an action is triggered by an email message. See
    * T251 for more details.
    *
-   * @param   string The "Message-ID" of the email which precedes this one.
+   * @param   string $id The "Message-ID" of the email which precedes this one.
    * @return  this
    */
   public function setParentMessageID($id) {
     $this->setParam('parent-message-id', $id);
     return $this;
   }
 
   public function getParentMessageID() {
     return $this->getParam('parent-message-id');
   }
 
   public function getSubject() {
     return $this->getParam('subject');
   }
 
   public function addTos(array $phids) {
     $phids = array_unique($phids);
     $this->setParam('to', $phids);
     return $this;
   }
 
   public function addRawTos(array $raw_email) {
 
     // Strip addresses down to bare emails, since the MailAdapter API currently
     // requires we pass it just the address (like `alincoln@logcabin.org`), not
     // a full string like `"Abraham Lincoln" <alincoln@logcabin.org>`.
     foreach ($raw_email as $key => $email) {
       $object = new PhutilEmailAddress($email);
       $raw_email[$key] = $object->getAddress();
     }
 
     $this->setParam('raw-to', $raw_email);
     return $this;
   }
 
   public function addCCs(array $phids) {
     $phids = array_unique($phids);
     $this->setParam('cc', $phids);
     return $this;
   }
 
   public function setExcludeMailRecipientPHIDs(array $exclude) {
     $this->setParam('exclude', $exclude);
     return $this;
   }
 
   private function getExcludeMailRecipientPHIDs() {
     return $this->getParam('exclude', array());
   }
 
   public function setMutedPHIDs(array $muted) {
     $this->setParam('muted', $muted);
     return $this;
   }
 
   private function getMutedPHIDs() {
     return $this->getParam('muted', array());
   }
 
   public function setForceHeraldMailRecipientPHIDs(array $force) {
     $this->setParam('herald-force-recipients', $force);
     return $this;
   }
 
   private function getForceHeraldMailRecipientPHIDs() {
     return $this->getParam('herald-force-recipients', array());
   }
 
   public function addPHIDHeaders($name, array $phids) {
     $phids = array_unique($phids);
     foreach ($phids as $phid) {
       $this->addHeader($name, '<'.$phid.'>');
     }
     return $this;
   }
 
   public function addHeader($name, $value) {
     $this->parameters['headers'][] = array($name, $value);
     return $this;
   }
 
   public function getHeaders() {
     return $this->getParam('headers', array());
   }
 
   public function addAttachment(PhabricatorMailAttachment $attachment) {
     $this->parameters['attachments'][] = $attachment->toDictionary();
     return $this;
   }
 
   public function getAttachments() {
     $dicts = $this->getParam('attachments', array());
 
     $result = array();
     foreach ($dicts as $dict) {
       $result[] = PhabricatorMailAttachment::newFromDictionary($dict);
     }
     return $result;
   }
 
   public function getAttachmentFilePHIDs() {
     $file_phids = array();
 
     $dictionaries = $this->getParam('attachments');
     if ($dictionaries) {
       foreach ($dictionaries as $dictionary) {
         $file_phid = idx($dictionary, 'filePHID');
         if ($file_phid) {
           $file_phids[] = $file_phid;
         }
       }
     }
 
     return $file_phids;
   }
 
   public function loadAttachedFiles(PhabricatorUser $viewer) {
     $file_phids = $this->getAttachmentFilePHIDs();
 
     if (!$file_phids) {
       return array();
     }
 
     return id(new PhabricatorFileQuery())
       ->setViewer($viewer)
       ->withPHIDs($file_phids)
       ->execute();
   }
 
   public function setAttachments(array $attachments) {
     assert_instances_of($attachments, 'PhabricatorMailAttachment');
     $this->setParam('attachments', mpull($attachments, 'toDictionary'));
     return $this;
   }
 
   public function setFrom($from) {
     $this->setParam('from', $from);
     $this->setActorPHID($from);
     return $this;
   }
 
   public function getFrom() {
     return $this->getParam('from');
   }
 
   public function setRawFrom($raw_email, $raw_name) {
     $this->setParam('raw-from', array($raw_email, $raw_name));
     return $this;
   }
 
   public function getRawFrom() {
     return $this->getParam('raw-from');
   }
 
   public function setReplyTo($reply_to) {
     $this->setParam('reply-to', $reply_to);
     return $this;
   }
 
   public function getReplyTo() {
     return $this->getParam('reply-to');
   }
 
   public function setSubject($subject) {
     $this->setParam('subject', $subject);
     return $this;
   }
 
   public function setSubjectPrefix($prefix) {
     $this->setParam('subject-prefix', $prefix);
     return $this;
   }
 
   public function getSubjectPrefix() {
     return $this->getParam('subject-prefix');
   }
 
   public function setVarySubjectPrefix($prefix) {
     $this->setParam('vary-subject-prefix', $prefix);
     return $this;
   }
 
   public function getVarySubjectPrefix() {
     return $this->getParam('vary-subject-prefix');
   }
 
   public function setBody($body) {
     $this->setParam('body', $body);
     return $this;
   }
 
   public function setSensitiveContent($bool) {
     $this->setParam('sensitive', $bool);
     return $this;
   }
 
   public function hasSensitiveContent() {
     return $this->getParam('sensitive', true);
   }
 
   public function setMustEncrypt($bool) {
     return $this->setParam('mustEncrypt', $bool);
   }
 
   public function getMustEncrypt() {
     return $this->getParam('mustEncrypt', false);
   }
 
   public function setMustEncryptURI($uri) {
     return $this->setParam('mustEncrypt.uri', $uri);
   }
 
   public function getMustEncryptURI() {
     return $this->getParam('mustEncrypt.uri');
   }
 
   public function setMustEncryptSubject($subject) {
     return $this->setParam('mustEncrypt.subject', $subject);
   }
 
   public function getMustEncryptSubject() {
     return $this->getParam('mustEncrypt.subject');
   }
 
   public function setMustEncryptReasons(array $reasons) {
     return $this->setParam('mustEncryptReasons', $reasons);
   }
 
   public function getMustEncryptReasons() {
     return $this->getParam('mustEncryptReasons', array());
   }
 
   public function setMailStamps(array $stamps) {
     return $this->setParam('stamps', $stamps);
   }
 
   public function getMailStamps() {
     return $this->getParam('stamps', array());
   }
 
   public function setMailStampMetadata($metadata) {
     return $this->setParam('stampMetadata', $metadata);
   }
 
   public function getMailStampMetadata() {
     return $this->getParam('stampMetadata', array());
   }
 
   public function getMailerKey() {
     return $this->getParam('mailer.key');
   }
 
   public function setTryMailers(array $mailers) {
     return $this->setParam('mailers.try', $mailers);
   }
 
   public function setHTMLBody($html) {
     $this->setParam('html-body', $html);
     return $this;
   }
 
   public function getBody() {
     return $this->getParam('body');
   }
 
   public function getHTMLBody() {
     return $this->getParam('html-body');
   }
 
   public function setIsErrorEmail($is_error) {
     $this->setParam('is-error', $is_error);
     return $this;
   }
 
   public function getIsErrorEmail() {
     return $this->getParam('is-error', false);
   }
 
   public function getToPHIDs() {
     return $this->getParam('to', array());
   }
 
   public function getRawToAddresses() {
     return $this->getParam('raw-to', array());
   }
 
   public function getCcPHIDs() {
     return $this->getParam('cc', array());
   }
 
   public function setMessageType($message_type) {
     return $this->setParam('message.type', $message_type);
   }
 
   public function getMessageType() {
     return $this->getParam(
       'message.type',
       PhabricatorMailEmailMessage::MESSAGETYPE);
   }
 
 
 
   /**
    * Force delivery of a message, even if recipients have preferences which
    * would otherwise drop the message.
    *
    * This is primarily intended to let users who don't want any email still
    * receive things like password resets.
    *
-   * @param bool  True to force delivery despite user preferences.
+   * @param bool $force True to force delivery despite user preferences.
    * @return this
    */
   public function setForceDelivery($force) {
     $this->setParam('force', $force);
     return $this;
   }
 
   public function getForceDelivery() {
     return $this->getParam('force', false);
   }
 
   /**
    * Flag that this is an auto-generated bulk message and should have bulk
    * headers added to it if appropriate. Broadly, this means some flavor of
    * "Precedence: bulk" or similar, but is implementation and configuration
    * dependent.
    *
-   * @param bool  True if the mail is automated bulk mail.
+   * @param bool $is_bulk True if the mail is automated bulk mail.
    * @return this
    */
   public function setIsBulk($is_bulk) {
     $this->setParam('is-bulk', $is_bulk);
     return $this;
   }
 
   public function getIsBulk() {
     return $this->getParam('is-bulk');
   }
 
   /**
    * Use this method to set an ID used for message threading. MetaMTA will
    * set appropriate headers (Message-ID, In-Reply-To, References and
    * Thread-Index) based on the capabilities of the underlying mailer.
    *
-   * @param string  Unique identifier, appropriate for use in a Message-ID,
-   *                In-Reply-To or References headers.
-   * @param bool    If true, indicates this is the first message in the thread.
+   * @param string  $thread_id Unique identifier, appropriate for use in a
+   *                Message-ID, In-Reply-To or References headers.
+   * @param bool?   $is_first_message If true, indicates this is the first
+   *                message in the thread.
    * @return this
    */
   public function setThreadID($thread_id, $is_first_message = false) {
     $this->setParam('thread-id', $thread_id);
     $this->setParam('is-first-message', $is_first_message);
     return $this;
   }
 
   public function getThreadID() {
     return $this->getParam('thread-id');
   }
 
   public function getIsFirstMessage() {
     return (bool)$this->getParam('is-first-message');
   }
 
   /**
    * Save a newly created mail to the database. The mail will eventually be
    * delivered by the MetaMTA daemon.
    *
    * @return this
    */
   public function saveAndSend() {
     return $this->save();
   }
 
   /**
    * @return this
    */
   public function save() {
     if ($this->getID()) {
       return parent::save();
     }
 
     // NOTE: When mail is sent from CLI scripts that run tasks in-process, we
     // may re-enter this method from within scheduleTask(). The implementation
     // is intended to avoid anything awkward if we end up reentering this
     // method.
 
     $this->openTransaction();
       // Save to generate a mail ID and PHID.
       $result = parent::save();
 
       // Write the recipient edges.
       $editor = new PhabricatorEdgeEditor();
       $edge_type = PhabricatorMetaMTAMailHasRecipientEdgeType::EDGECONST;
       $recipient_phids = array_merge(
         $this->getToPHIDs(),
         $this->getCcPHIDs());
       $expanded_phids = $this->expandRecipients($recipient_phids);
       $all_phids = array_unique(array_merge(
         $recipient_phids,
         $expanded_phids));
       foreach ($all_phids as $curr_phid) {
         $editor->addEdge($this->getPHID(), $edge_type, $curr_phid);
       }
       $editor->save();
 
     $this->saveTransaction();
 
     // Queue a task to send this mail.
     $mailer_task = PhabricatorWorker::scheduleTask(
       'PhabricatorMetaMTAWorker',
       $this->getID(),
       array(
         'priority' => PhabricatorWorker::PRIORITY_ALERTS,
       ));
 
     return $result;
   }
 
   /**
    * Attempt to deliver an email immediately, in this process.
    *
    * @return void
    */
   public function sendNow() {
     if ($this->getStatus() != PhabricatorMailOutboundStatus::STATUS_QUEUE) {
       throw new Exception(pht('Trying to send an already-sent mail!'));
     }
 
     $mailers = self::newMailers(
       array(
         'outbound' => true,
         'media' => array(
           $this->getMessageType(),
         ),
       ));
 
     $try_mailers = $this->getParam('mailers.try');
     if ($try_mailers) {
       $mailers = mpull($mailers, null, 'getKey');
       $mailers = array_select_keys($mailers, $try_mailers);
     }
 
     return $this->sendWithMailers($mailers);
   }
 
   public static function newMailers(array $constraints) {
     PhutilTypeSpec::checkMap(
       $constraints,
       array(
         'types' => 'optional list<string>',
         'inbound' => 'optional bool',
         'outbound' => 'optional bool',
         'media' => 'optional list<string>',
       ));
 
     $mailers = array();
 
     $config = PhabricatorEnv::getEnvConfig('cluster.mailers');
 
     $adapters = PhabricatorMailAdapter::getAllAdapters();
     $next_priority = -1;
 
     foreach ($config as $spec) {
       $type = $spec['type'];
       if (!isset($adapters[$type])) {
         throw new Exception(
           pht(
             'Unknown mailer ("%s")!',
             $type));
       }
 
       $key = $spec['key'];
       $mailer = id(clone $adapters[$type])
         ->setKey($key);
 
       $priority = idx($spec, 'priority');
       if (!$priority) {
         $priority = $next_priority;
         $next_priority--;
       }
       $mailer->setPriority($priority);
 
       $defaults = $mailer->newDefaultOptions();
       $options = idx($spec, 'options', array()) + $defaults;
       $mailer->setOptions($options);
 
       $mailer->setSupportsInbound(idx($spec, 'inbound', true));
       $mailer->setSupportsOutbound(idx($spec, 'outbound', true));
 
       $media = idx($spec, 'media');
       if ($media !== null) {
         $mailer->setMedia($media);
       }
 
       $mailers[] = $mailer;
     }
 
     // Remove mailers with the wrong types.
     if (isset($constraints['types'])) {
       $types = $constraints['types'];
       $types = array_fuse($types);
       foreach ($mailers as $key => $mailer) {
         $mailer_type = $mailer->getAdapterType();
         if (!isset($types[$mailer_type])) {
           unset($mailers[$key]);
         }
       }
     }
 
     // If we're only looking for inbound mailers, remove mailers with inbound
     // support disabled.
     if (!empty($constraints['inbound'])) {
       foreach ($mailers as $key => $mailer) {
         if (!$mailer->getSupportsInbound()) {
           unset($mailers[$key]);
         }
       }
     }
 
     // If we're only looking for outbound mailers, remove mailers with outbound
     // support disabled.
     if (!empty($constraints['outbound'])) {
       foreach ($mailers as $key => $mailer) {
         if (!$mailer->getSupportsOutbound()) {
           unset($mailers[$key]);
         }
       }
     }
 
     // Select only the mailers which can transmit messages with requested media
     // types.
     if (!empty($constraints['media'])) {
       foreach ($mailers as $key => $mailer) {
         $supports_any = false;
         foreach ($constraints['media'] as $medium) {
           if ($mailer->supportsMessageType($medium)) {
             $supports_any = true;
             break;
           }
         }
 
         if (!$supports_any) {
           unset($mailers[$key]);
         }
       }
     }
 
     $sorted = array();
     $groups = mgroup($mailers, 'getPriority');
     krsort($groups);
     foreach ($groups as $group) {
       // Reorder services within the same priority group randomly.
       shuffle($group);
       foreach ($group as $mailer) {
         $sorted[] = $mailer;
       }
     }
 
     return $sorted;
   }
 
   public function sendWithMailers(array $mailers) {
     if (!$mailers) {
       $any_mailers = self::newMailers(array());
 
       // NOTE: We can end up here with some custom list of "$mailers", like
       // from a unit test. In that case, this message could be misleading. We
       // can't really tell if the caller made up the list, so just assume they
       // aren't tricking us.
 
       if ($any_mailers) {
         $void_message = pht(
           'No configured mailers support sending outbound mail.');
       } else {
         $void_message = pht(
           'No mailers are configured.');
       }
 
       return $this
         ->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID)
         ->setMessage($void_message)
         ->save();
     }
 
     $actors = $this->loadAllActors();
 
     // If we're sending one mail to everyone, some recipients will be in
     // "Cc" rather than "To". We'll move them to "To" later (or supply a
     // dummy "To") but need to look for the recipient in either the
     // "To" or "Cc" fields here.
     $target_phid = head($this->getToPHIDs());
     if (!$target_phid) {
       $target_phid = head($this->getCcPHIDs());
     }
     $preferences = $this->loadPreferences($target_phid);
 
     // Attach any files we're about to send to this message, so the recipients
     // can view them.
     $viewer = PhabricatorUser::getOmnipotentUser();
     $files = $this->loadAttachedFiles($viewer);
     foreach ($files as $file) {
       $file->attachToObject($this->getPHID());
     }
 
     $type_map = PhabricatorMailExternalMessage::getAllMessageTypes();
     $type = idx($type_map, $this->getMessageType());
     if (!$type) {
       throw new Exception(
         pht(
           'Unable to send message with unknown message type "%s".',
           $type));
     }
 
     $exceptions = array();
     foreach ($mailers as $mailer) {
       try {
         $message = $type->newMailMessageEngine()
           ->setMailer($mailer)
           ->setMail($this)
           ->setActors($actors)
           ->setPreferences($preferences)
           ->newMessage($mailer);
       } catch (Exception $ex) {
         $exceptions[] = $ex;
         continue;
       }
 
       if (!$message) {
         // If we don't get a message back, that means the mail doesn't actually
         // need to be sent (for example, because recipients have declined to
         // receive the mail). Void it and return.
         return $this
           ->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID)
           ->save();
       }
 
       try {
         $mailer->sendMessage($message);
       } catch (PhabricatorMetaMTAPermanentFailureException $ex) {
         // If any mailer raises a permanent failure, stop trying to send the
         // mail with other mailers.
         $this
           ->setStatus(PhabricatorMailOutboundStatus::STATUS_FAIL)
           ->setMessage($ex->getMessage())
           ->save();
 
         throw $ex;
       } catch (Exception $ex) {
         $exceptions[] = $ex;
         continue;
       }
 
       // Keep track of which mailer actually ended up accepting the message.
       $mailer_key = $mailer->getKey();
       if ($mailer_key !== null) {
         $this->setParam('mailer.key', $mailer_key);
       }
 
       // Now that we sent the message, store the final deliverability outcomes
       // and reasoning so we can explain why things happened the way they did.
       $actor_list = array();
       foreach ($actors as $actor) {
         $actor_list[$actor->getPHID()] = array(
           'deliverable' => $actor->isDeliverable(),
           'reasons' => $actor->getDeliverabilityReasons(),
         );
       }
       $this->setParam('actors.sent', $actor_list);
       $this->setParam('routing.sent', $this->getParam('routing'));
       $this->setParam('routingmap.sent', $this->getRoutingRuleMap());
 
       return $this
         ->setStatus(PhabricatorMailOutboundStatus::STATUS_SENT)
         ->save();
     }
 
     // If we make it here, no mailer could send the mail but no mailer failed
     // permanently either. We update the error message for the mail, but leave
     // it in the current status (usually, STATUS_QUEUE) and try again later.
 
     $messages = array();
     foreach ($exceptions as $ex) {
       $messages[] = $ex->getMessage();
     }
     $messages = implode("\n\n", $messages);
 
     $this
       ->setMessage($messages)
       ->save();
 
     if (count($exceptions) === 1) {
       throw head($exceptions);
     }
 
     throw new PhutilAggregateException(
       pht('Encountered multiple exceptions while transmitting mail.'),
       $exceptions);
   }
 
 
   public static function shouldMailEachRecipient() {
     return PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient');
   }
 
 
 /* -(  Managing Recipients  )------------------------------------------------ */
 
 
   /**
    * Get all of the recipients for this mail, after preference filters are
    * applied. This list has all objects to whom delivery will be attempted.
    *
    * Note that this expands recipients into their members, because delivery
    * is never directly attempted to aggregate actors like projects.
    *
    * @return  list<phid>  A list of all recipients to whom delivery will be
    *                      attempted.
    * @task recipients
    */
   public function buildRecipientList() {
     $actors = $this->loadAllActors();
     $actors = $this->filterDeliverableActors($actors);
     return mpull($actors, 'getPHID');
   }
 
   public function loadAllActors() {
     $actor_phids = $this->getExpandedRecipientPHIDs();
     return $this->loadActors($actor_phids);
   }
 
   public function getExpandedRecipientPHIDs() {
     $actor_phids = $this->getAllActorPHIDs();
     return $this->expandRecipients($actor_phids);
   }
 
   private function getAllActorPHIDs() {
     return array_merge(
       array($this->getParam('from')),
       $this->getToPHIDs(),
       $this->getCcPHIDs());
   }
 
   /**
    * Expand a list of recipient PHIDs (possibly including aggregate recipients
    * like projects) into a deaggregated list of individual recipient PHIDs.
    * For example, this will expand project PHIDs into a list of the project's
    * members.
    *
-   * @param list<phid>  List of recipient PHIDs, possibly including aggregate
-   *                    recipients.
+   * @param list<phid>  $phids List of recipient PHIDs, possibly including
+   *                    aggregate recipients.
    * @return list<phid> Deaggregated list of mailable recipients.
    */
   public function expandRecipients(array $phids) {
     if ($this->recipientExpansionMap === null) {
       $all_phids = $this->getAllActorPHIDs();
       $this->recipientExpansionMap = id(new PhabricatorMetaMTAMemberQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withPHIDs($all_phids)
         ->execute();
     }
 
     $results = array();
     foreach ($phids as $phid) {
       foreach ($this->recipientExpansionMap[$phid] as $recipient_phid) {
         $results[$recipient_phid] = $recipient_phid;
       }
     }
 
     return array_keys($results);
   }
 
   private function filterDeliverableActors(array $actors) {
     assert_instances_of($actors, 'PhabricatorMetaMTAActor');
     $deliverable_actors = array();
     foreach ($actors as $phid => $actor) {
       if ($actor->isDeliverable()) {
         $deliverable_actors[$phid] = $actor;
       }
     }
     return $deliverable_actors;
   }
 
   private function loadActors(array $actor_phids) {
     $actor_phids = array_filter($actor_phids);
     $viewer = PhabricatorUser::getOmnipotentUser();
 
     $actors = id(new PhabricatorMetaMTAActorQuery())
       ->setViewer($viewer)
       ->withPHIDs($actor_phids)
       ->execute();
 
     if (!$actors) {
       return array();
     }
 
     if ($this->getForceDelivery()) {
       // If we're forcing delivery, skip all the opt-out checks. We don't
       // bother annotating reasoning on the mail in this case because it should
       // always be obvious why the mail hit this rule (e.g., it is a password
       // reset mail).
       foreach ($actors as $actor) {
         $actor->setDeliverable(PhabricatorMetaMTAActor::REASON_FORCE);
       }
       return $actors;
     }
 
     // Exclude explicit recipients.
     foreach ($this->getExcludeMailRecipientPHIDs() as $phid) {
       $actor = idx($actors, $phid);
       if (!$actor) {
         continue;
       }
       $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_RESPONSE);
     }
 
     // Before running more rules, save a list of the actors who were
     // deliverable before we started running preference-based rules. This stops
     // us from trying to send mail to disabled users just because a Herald rule
     // added them, for example.
     $deliverable = array();
     foreach ($actors as $phid => $actor) {
       if ($actor->isDeliverable()) {
         $deliverable[] = $phid;
       }
     }
 
     // Exclude muted recipients. We're doing this after saving deliverability
     // so that Herald "Send me an email" actions can still punch through a
     // mute.
 
     foreach ($this->getMutedPHIDs() as $muted_phid) {
       $muted_actor = idx($actors, $muted_phid);
       if (!$muted_actor) {
         continue;
       }
       $muted_actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_MUTED);
     }
 
     // For the rest of the rules, order matters. We're going to run all the
     // possible rules in order from weakest to strongest, and let the strongest
     // matching rule win. The weaker rules leave annotations behind which help
     // users understand why the mail was routed the way it was.
 
     // Exclude the actor if their preferences are set.
     $from_phid = $this->getParam('from');
     $from_actor = idx($actors, $from_phid);
     if ($from_actor) {
       $from_user = id(new PhabricatorPeopleQuery())
         ->setViewer($viewer)
         ->withPHIDs(array($from_phid))
         ->needUserSettings(true)
         ->execute();
       $from_user = head($from_user);
       if ($from_user) {
         $pref_key = PhabricatorEmailSelfActionsSetting::SETTINGKEY;
         $exclude_self = $from_user->getUserSetting($pref_key);
         if ($exclude_self) {
           $from_actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_SELF);
         }
       }
     }
 
     $all_prefs = id(new PhabricatorUserPreferencesQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withUserPHIDs($actor_phids)
       ->needSyntheticPreferences(true)
       ->execute();
     $all_prefs = mpull($all_prefs, null, 'getUserPHID');
 
     $value_email = PhabricatorEmailTagsSetting::VALUE_EMAIL;
 
     // Exclude all recipients who have set preferences to not receive this type
     // of email (for example, a user who says they don't want emails about task
     // CC changes).
     $tags = $this->getParam('mailtags');
     if ($tags) {
       foreach ($all_prefs as $phid => $prefs) {
         $user_mailtags = $prefs->getSettingValue(
           PhabricatorEmailTagsSetting::SETTINGKEY);
 
         // The user must have elected to receive mail for at least one
         // of the mailtags.
         $send = false;
         foreach ($tags as $tag) {
           if (((int)idx($user_mailtags, $tag, $value_email)) == $value_email) {
             $send = true;
             break;
           }
         }
 
         if (!$send) {
           $actors[$phid]->setUndeliverable(
             PhabricatorMetaMTAActor::REASON_MAILTAGS);
         }
       }
     }
 
     foreach ($deliverable as $phid) {
       switch ($this->getRoutingRule($phid)) {
         case PhabricatorMailRoutingRule::ROUTE_AS_NOTIFICATION:
           $actors[$phid]->setUndeliverable(
             PhabricatorMetaMTAActor::REASON_ROUTE_AS_NOTIFICATION);
           break;
         case PhabricatorMailRoutingRule::ROUTE_AS_MAIL:
           $actors[$phid]->setDeliverable(
             PhabricatorMetaMTAActor::REASON_ROUTE_AS_MAIL);
           break;
         default:
           // No change.
           break;
       }
     }
 
     // If recipients were initially deliverable and were added by "Send me an
     // email" Herald rules, annotate them as such and make them deliverable
     // again, overriding any changes made by the "self mail" and "mail tags"
     // settings.
     $force_recipients = $this->getForceHeraldMailRecipientPHIDs();
     $force_recipients = array_fuse($force_recipients);
     if ($force_recipients) {
       foreach ($deliverable as $phid) {
         if (isset($force_recipients[$phid])) {
           $actors[$phid]->setDeliverable(
             PhabricatorMetaMTAActor::REASON_FORCE_HERALD);
         }
       }
     }
 
     // Exclude recipients who don't want any mail. This rule is very strong
     // and runs last.
     foreach ($all_prefs as $phid => $prefs) {
       $exclude = $prefs->getSettingValue(
         PhabricatorEmailNotificationsSetting::SETTINGKEY);
       if ($exclude) {
         $actors[$phid]->setUndeliverable(
           PhabricatorMetaMTAActor::REASON_MAIL_DISABLED);
       }
     }
 
     // Unless delivery was forced earlier (password resets, confirmation mail),
     // never send mail to unverified addresses.
     foreach ($actors as $phid => $actor) {
       if ($actor->getIsVerified()) {
         continue;
       }
 
       $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNVERIFIED);
     }
 
     return $actors;
   }
 
   public function getDeliveredHeaders() {
     return $this->getParam('headers.sent');
   }
 
   public function setDeliveredHeaders(array $headers) {
     $headers = $this->flattenHeaders($headers);
     return $this->setParam('headers.sent', $headers);
   }
 
   public function getUnfilteredHeaders() {
     $unfiltered = $this->getParam('headers.unfiltered');
 
     if ($unfiltered === null) {
       // Older versions of Phabricator did not filter headers, and thus did
       // not record unfiltered headers. If we don't have unfiltered header
       // data just return the delivered headers for compatibility.
       return $this->getDeliveredHeaders();
     }
 
     return $unfiltered;
   }
 
   public function setUnfilteredHeaders(array $headers) {
     $headers = $this->flattenHeaders($headers);
     return $this->setParam('headers.unfiltered', $headers);
   }
 
   private function flattenHeaders(array $headers) {
     assert_instances_of($headers, 'PhabricatorMailHeader');
 
     $list = array();
     foreach ($list as $header) {
       $list[] = array(
         $header->getName(),
         $header->getValue(),
       );
     }
 
     return $list;
   }
 
   public function getDeliveredActors() {
     return $this->getParam('actors.sent');
   }
 
   public function getDeliveredRoutingRules() {
     return $this->getParam('routing.sent');
   }
 
   public function getDeliveredRoutingMap() {
     return $this->getParam('routingmap.sent');
   }
 
   public function getDeliveredBody() {
     return $this->getParam('body.sent');
   }
 
   public function setDeliveredBody($body) {
     return $this->setParam('body.sent', $body);
   }
 
   public function getURI() {
     return '/mail/detail/'.$this->getID().'/';
   }
 
 
 /* -(  Routing  )------------------------------------------------------------ */
 
 
   public function addRoutingRule($routing_rule, $phids, $reason_phid) {
     $routing = $this->getParam('routing', array());
     $routing[] = array(
       'routingRule' => $routing_rule,
       'phids' => $phids,
       'reasonPHID' => $reason_phid,
     );
     $this->setParam('routing', $routing);
 
     // Throw the routing map away so we rebuild it.
     $this->routingMap = null;
 
     return $this;
   }
 
   private function getRoutingRule($phid) {
     $map = $this->getRoutingRuleMap();
 
     $info = idx($map, $phid, idx($map, 'default'));
     if ($info) {
       return idx($info, 'rule');
     }
 
     return null;
   }
 
   private function getRoutingRuleMap() {
     if ($this->routingMap === null) {
       $map = array();
 
       $routing = $this->getParam('routing', array());
       foreach ($routing as $route) {
         $phids = $route['phids'];
         if ($phids === null) {
           $phids = array('default');
         }
 
         foreach ($phids as $phid) {
           $new_rule = $route['routingRule'];
 
           $current_rule = idx($map, $phid);
           if ($current_rule === null) {
             $is_stronger = true;
           } else {
             $is_stronger = PhabricatorMailRoutingRule::isStrongerThan(
               $new_rule,
               $current_rule);
           }
 
           if ($is_stronger) {
             $map[$phid] = array(
               'rule' => $new_rule,
               'reason' => $route['reasonPHID'],
             );
           }
         }
       }
 
       $this->routingMap = $map;
     }
 
     return $this->routingMap;
   }
 
 /* -(  Preferences  )-------------------------------------------------------- */
 
 
   private function loadPreferences($target_phid) {
     $viewer = PhabricatorUser::getOmnipotentUser();
 
     if (self::shouldMailEachRecipient()) {
       $preferences = id(new PhabricatorUserPreferencesQuery())
         ->setViewer($viewer)
         ->withUserPHIDs(array($target_phid))
         ->needSyntheticPreferences(true)
         ->executeOne();
       if ($preferences) {
         return $preferences;
       }
     }
 
     return PhabricatorUserPreferences::loadGlobalPreferences($viewer);
   }
 
   public function shouldRenderMailStampsInBody($viewer) {
     $preferences = $this->loadPreferences($viewer->getPHID());
     $value = $preferences->getSettingValue(
       PhabricatorEmailStampsSetting::SETTINGKEY);
 
     return ($value == PhabricatorEmailStampsSetting::VALUE_BODY_STAMPS);
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
     );
   }
 
   public function getPolicy($capability) {
     return PhabricatorPolicies::POLICY_NOONE;
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     $actor_phids = $this->getExpandedRecipientPHIDs();
     return in_array($viewer->getPHID(), $actor_phids);
   }
 
   public function describeAutomaticCapability($capability) {
     return pht(
       'The mail sender and message recipients can always see the mail.');
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
 
     $files = $this->loadAttachedFiles($engine->getViewer());
     foreach ($files as $file) {
       $engine->destroyObject($file);
     }
 
     $this->delete();
   }
 
 }
diff --git a/src/applications/metamta/util/PhabricatorMailUtil.php b/src/applications/metamta/util/PhabricatorMailUtil.php
index 270e9786f3..66f2289227 100644
--- a/src/applications/metamta/util/PhabricatorMailUtil.php
+++ b/src/applications/metamta/util/PhabricatorMailUtil.php
@@ -1,118 +1,118 @@
 <?php
 
 final class PhabricatorMailUtil
   extends Phobject {
 
   /**
    * Normalize an email address for comparison or lookup.
    *
    * Phabricator can be configured to prepend a prefix to all reply addresses,
    * which can make forwarding rules easier to write. This method strips the
    * prefix if it is present, and normalizes casing and whitespace.
    *
-   * @param PhutilEmailAddress Email address.
+   * @param PhutilEmailAddress $address Email address.
    * @return PhutilEmailAddress Normalized address.
    */
   public static function normalizeAddress(PhutilEmailAddress $address) {
     $raw_address = $address->getAddress();
     $raw_address = phutil_utf8_strtolower($raw_address);
     $raw_address = trim($raw_address);
 
     // If a mailbox prefix is configured and present, strip it off.
     $prefix_key = 'metamta.single-reply-handler-prefix';
     $prefix = PhabricatorEnv::getEnvConfig($prefix_key);
 
     if (phutil_nonempty_string($prefix)) {
       $prefix = $prefix.'+';
       $len = strlen($prefix);
 
       if (!strncasecmp($raw_address, $prefix, $len)) {
         $raw_address = substr($raw_address, $len);
       }
     }
 
     return id(clone $address)
       ->setAddress($raw_address);
   }
 
   /**
    * Determine if two inbound email addresses are effectively identical.
    *
    * This method strips and normalizes addresses so that equivalent variations
    * are correctly detected as identical. For example, these addresses are all
    * considered to match one another:
    *
    *   "Abraham Lincoln" <alincoln@example.com>
    *   alincoln@example.com
    *   <ALincoln@example.com>
    *   "Abraham" <phabricator+ALINCOLN@EXAMPLE.COM> # With configured prefix.
    *
-   * @param   PhutilEmailAddress Email address.
-   * @param   PhutilEmailAddress Another email address.
+   * @param   PhutilEmailAddress $u Email address.
+   * @param   PhutilEmailAddress $v Another email address.
    * @return  bool True if addresses are effectively the same address.
    */
   public static function matchAddresses(
     PhutilEmailAddress $u,
     PhutilEmailAddress $v) {
 
     $u = self::normalizeAddress($u);
     $v = self::normalizeAddress($v);
 
     return ($u->getAddress() === $v->getAddress());
   }
 
   public static function isReservedAddress(PhutilEmailAddress $address) {
     $address = self::normalizeAddress($address);
     $local = $address->getLocalPart();
 
     $reserved = array(
       'admin',
       'administrator',
       'hostmaster',
       'list',
       'list-request',
       'majordomo',
       'postmaster',
       'root',
       'ssl-admin',
       'ssladmin',
       'ssladministrator',
       'sslwebmaster',
       'sysadmin',
       'uucp',
       'webmaster',
 
       'noreply',
       'no-reply',
     );
 
     $reserved = array_fuse($reserved);
 
     if (isset($reserved[$local])) {
       return true;
     }
 
     $default_address = id(new PhabricatorMailEmailEngine())
       ->newDefaultEmailAddress();
     if (self::matchAddresses($address, $default_address)) {
       return true;
     }
 
     $void_address = id(new PhabricatorMailEmailEngine())
       ->newVoidEmailAddress();
     if (self::matchAddresses($address, $void_address)) {
       return true;
     }
 
     return false;
   }
 
   public static function isUserAddress(PhutilEmailAddress $address) {
     $user_email = id(new PhabricatorUserEmail())->loadOneWhere(
       'address = %s',
       $address->getAddress());
 
     return (bool)$user_email;
   }
 
 }
diff --git a/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php b/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php
index 6622f0afeb..2178bfc130 100644
--- a/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php
+++ b/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php
@@ -1,223 +1,223 @@
 <?php
 
 /**
  * Render the body of an application email by building it up section-by-section.
  *
  * @task compose  Composition
  * @task render   Rendering
  */
 final class PhabricatorMetaMTAMailBody extends Phobject {
 
   private $sections = array();
   private $htmlSections = array();
   private $attachments = array();
 
   private $viewer;
   private $contextObject;
 
   public function getViewer() {
     return $this->viewer;
   }
 
   public function setViewer($viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function setContextObject($context_object) {
     $this->contextObject = $context_object;
     return $this;
   }
 
   public function getContextObject() {
     return $this->contextObject;
   }
 
 
 /* -(  Composition  )-------------------------------------------------------- */
 
 
   /**
    * Add a raw block of text to the email. This will be rendered as-is.
    *
-   * @param string Block of text.
+   * @param string $text Block of text.
    * @return this
    * @task compose
    */
   public function addRawSection($text) {
     if (strlen($text)) {
       $text = rtrim($text);
       $this->sections[] = $text;
       $this->htmlSections[] = phutil_escape_html_newlines(
         phutil_tag('div', array(), $text));
     }
     return $this;
   }
 
   public function addRemarkupSection($header, $text) {
     try {
       $engine = $this->newMarkupEngine()
         ->setMode(PhutilRemarkupEngine::MODE_TEXT);
 
       $styled_text = $engine->markupText($text);
       $this->addPlaintextSection($header, $styled_text);
     } catch (Exception $ex) {
       phlog($ex);
       $this->addTextSection($header, $text);
     }
 
     try {
       $mail_engine = $this->newMarkupEngine()
         ->setMode(PhutilRemarkupEngine::MODE_HTML_MAIL);
 
       $html = $mail_engine->markupText($text);
       $this->addHTMLSection($header, $html);
     } catch (Exception $ex) {
       phlog($ex);
       $this->addHTMLSection($header, $text);
     }
 
     return $this;
   }
 
   public function addRawPlaintextSection($text) {
     if (strlen($text)) {
       $text = rtrim($text);
       $this->sections[] = $text;
     }
     return $this;
   }
 
   public function addRawHTMLSection($html) {
     $this->htmlSections[] = phutil_safe_html($html);
     return $this;
   }
 
 
   /**
    * Add a block of text with a section header. This is rendered like this:
    *
    *    HEADER
    *      Text is indented.
    *
-   * @param string Header text.
-   * @param string Section text.
+   * @param string $header Header text.
+   * @param string $section Section text.
    * @return this
    * @task compose
    */
   public function addTextSection($header, $section) {
     if ($section instanceof PhabricatorMetaMTAMailSection) {
       $plaintext = $section->getPlaintext();
       $html = $section->getHTML();
     } else {
       $plaintext = $section;
       $html = phutil_escape_html_newlines(phutil_tag('div', array(), $section));
     }
 
     $this->addPlaintextSection($header, $plaintext);
     $this->addHTMLSection($header, $html);
     return $this;
   }
 
   public function addPlaintextSection($header, $text, $indent = true) {
     if ($indent) {
       $text = $this->indent($text);
     }
     $this->sections[] = $header."\n".$text;
     return $this;
   }
 
   public function addHTMLSection($header, $html_fragment) {
     if ($header !== null) {
       $header = phutil_tag('strong', array(), $header);
     }
 
     $this->htmlSections[] = array(
       phutil_tag(
         'div',
         array(),
         array(
           $header,
           phutil_tag('div', array(), $html_fragment),
         )),
     );
     return $this;
   }
 
   public function addLinkSection($header, $link) {
     $html = phutil_tag('a', array('href' => $link), $link);
     $this->addPlaintextSection($header, $link);
     $this->addHTMLSection($header, $html);
     return $this;
   }
 
 
   /**
    * Add an attachment.
    *
-   * @param PhabricatorMailAttachment Attachment.
+   * @param PhabricatorMailAttachment $attachment Attachment.
    * @return this
    * @task compose
    */
   public function addAttachment(PhabricatorMailAttachment $attachment) {
     $this->attachments[] = $attachment;
     return $this;
   }
 
 
 /* -(  Rendering  )---------------------------------------------------------- */
 
 
   /**
    * Render the email body.
    *
    * @return string Rendered body.
    * @task render
    */
   public function render() {
     return implode("\n\n", $this->sections)."\n";
   }
 
   public function renderHTML() {
     $br = phutil_tag('br');
     $body = phutil_implode_html($br, $this->htmlSections);
     return (string)hsprintf('%s', array($body, $br));
   }
 
   /**
    * Retrieve attachments.
    *
    * @return list<PhabricatorMailAttachment> Attachments.
    * @task render
    */
   public function getAttachments() {
     return $this->attachments;
   }
 
 
   /**
    * Indent a block of text for rendering under a section heading.
    *
-   * @param string Text to indent.
+   * @param string $text Text to indent.
    * @return string Indented text.
    * @task render
    */
   private function indent($text) {
     return rtrim("  ".str_replace("\n", "\n  ", $text));
   }
 
 
   private function newMarkupEngine() {
     $engine = PhabricatorMarkupEngine::newMarkupEngine(array())
       ->setConfig('viewer', $this->getViewer())
       ->setConfig('uri.base', PhabricatorEnv::getProductionURI('/'));
 
     $context = $this->getContextObject();
     if ($context) {
       $engine->setConfig('contextObject', $context);
     }
 
     return $engine;
   }
 
 }
diff --git a/src/applications/notification/query/PhabricatorNotificationQuery.php b/src/applications/notification/query/PhabricatorNotificationQuery.php
index 021d666b13..3c2a26a217 100644
--- a/src/applications/notification/query/PhabricatorNotificationQuery.php
+++ b/src/applications/notification/query/PhabricatorNotificationQuery.php
@@ -1,196 +1,196 @@
 <?php
 
 /**
  * @task config Configuring the Query
  * @task exec   Query Execution
  */
 final class PhabricatorNotificationQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $userPHIDs;
   private $keys;
   private $unread;
 
 
 /* -(  Configuring the Query  )---------------------------------------------- */
 
 
   public function withUserPHIDs(array $user_phids) {
     $this->userPHIDs = $user_phids;
     return $this;
   }
 
   public function withKeys(array $keys) {
     $this->keys = $keys;
     return $this;
   }
 
 
   /**
    * Filter results by read/unread status. Note that `true` means to return
    * only unread notifications, while `false` means to return only //read//
    * notifications. The default is `null`, which returns both.
    *
-   * @param mixed True or false to filter results by read status. Null to remove
-   *              the filter.
+   * @param mixed $unread True or false to filter results by read status. Null
+   *              to remove the filter.
    * @return this
    * @task config
    */
   public function withUnread($unread) {
     $this->unread = $unread;
     return $this;
   }
 
 
 /* -(  Query Execution  )---------------------------------------------------- */
 
 
   protected function loadPage() {
     $story_table = new PhabricatorFeedStoryData();
     $notification_table = new PhabricatorFeedStoryNotification();
 
     $conn = $story_table->establishConnection('r');
 
     $data = queryfx_all(
       $conn,
       'SELECT story.*, notification.hasViewed FROM %R notification
          JOIN %R story ON notification.chronologicalKey = story.chronologicalKey
          %Q
          ORDER BY notification.chronologicalKey DESC
          %Q',
       $notification_table,
       $story_table,
       $this->buildWhereClause($conn),
       $this->buildLimitClause($conn));
 
     return $data;
   }
 
   protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
     $where = parent::buildWhereClauseParts($conn);
 
     if ($this->userPHIDs !== null) {
       $where[] = qsprintf(
         $conn,
         'notification.userPHID IN (%Ls)',
         $this->userPHIDs);
     }
 
     if ($this->unread !== null) {
       $where[] = qsprintf(
         $conn,
         'notification.hasViewed = %d',
         (int)!$this->unread);
     }
 
     if ($this->keys !== null) {
       $where[] = qsprintf(
         $conn,
         'notification.chronologicalKey IN (%Ls)',
         $this->keys);
     }
 
     return $where;
   }
 
   protected function willFilterPage(array $rows) {
     // See T13623. The policy model here is outdated and awkward.
 
     // Users may have notifications about objects they can no longer see.
     // Two ways this can arise: destroy an object; or change an object's
     // view policy to exclude a user.
 
     // "PhabricatorFeedStory::loadAllFromRows()" does its own policy filtering.
     // This doesn't align well with modern query sequencing, but we should be
     // able to get away with it by loading here.
 
     // See T13623. Although most queries for notifications return unique
     // stories, this isn't a guarantee.
     $story_map = ipull($rows, null, 'chronologicalKey');
 
     $viewer = $this->getViewer();
     $stories = PhabricatorFeedStory::loadAllFromRows($story_map, $viewer);
     $stories = mpull($stories, null, 'getChronologicalKey');
 
     $results = array();
     foreach ($rows as $row) {
       $story_key = $row['chronologicalKey'];
       $has_viewed = $row['hasViewed'];
 
       if (!isset($stories[$story_key])) {
         // NOTE: We can't call "didRejectResult()" here because we don't have
         // a policy object to pass.
         continue;
       }
 
       $story = id(clone $stories[$story_key])
         ->setHasViewed($has_viewed);
 
       if (!$story->isVisibleInNotifications()) {
         continue;
       }
 
       $results[] = $story;
     }
 
     return $results;
   }
 
   protected function getDefaultOrderVector() {
     return array('key');
   }
 
   public function getBuiltinOrders() {
     return array(
       'newest' => array(
         'vector' => array('key'),
         'name' => pht('Creation (Newest First)'),
         'aliases' => array('created'),
       ),
       'oldest' => array(
         'vector' => array('-key'),
         'name' => pht('Creation (Oldest First)'),
       ),
     );
   }
 
   public function getOrderableColumns() {
     return array(
       'key' => array(
         'table' => 'notification',
         'column' => 'chronologicalKey',
         'type' => 'string',
         'unique' => true,
       ),
     );
   }
 
   protected function applyExternalCursorConstraintsToQuery(
     PhabricatorCursorPagedPolicyAwareQuery $subquery,
     $cursor) {
 
     $subquery
       ->withKeys(array($cursor))
       ->setLimit(1);
 
   }
 
   protected function newExternalCursorStringForResult($object) {
     return $object->getChronologicalKey();
   }
 
   protected function newPagingMapFromPartialObject($object) {
     return array(
       'key' => $object['chronologicalKey'],
     );
   }
 
   protected function getPrimaryTableAlias() {
     return 'notification';
   }
 
   public function getQueryApplicationClass() {
     return PhabricatorNotificationsApplication::class;
   }
 
 }
diff --git a/src/applications/passphrase/view/PassphraseCredentialControl.php b/src/applications/passphrase/view/PassphraseCredentialControl.php
index 98294832f7..09245157d2 100644
--- a/src/applications/passphrase/view/PassphraseCredentialControl.php
+++ b/src/applications/passphrase/view/PassphraseCredentialControl.php
@@ -1,208 +1,208 @@
 <?php
 
 final class PassphraseCredentialControl extends AphrontFormControl {
 
   private $options = array();
   private $credentialType;
   private $defaultUsername;
   private $allowNull;
 
   public function setAllowNull($allow_null) {
     $this->allowNull = $allow_null;
     return $this;
   }
 
   public function setDefaultUsername($default_username) {
     $this->defaultUsername = $default_username;
     return $this;
   }
 
   public function setCredentialType($credential_type) {
     $this->credentialType = $credential_type;
     return $this;
   }
 
   public function getCredentialType() {
     return $this->credentialType;
   }
 
   public function setOptions(array $options) {
     assert_instances_of($options, 'PassphraseCredential');
     $this->options = $options;
     return $this;
   }
 
   protected function getCustomControlClass() {
     return 'passphrase-credential-control';
   }
 
   protected function renderInput() {
 
     $options_map = array();
     foreach ($this->options as $option) {
       $options_map[$option->getPHID()] = pht(
         '%s %s',
         $option->getMonogram(),
         $option->getName());
     }
 
     // The user editing the form may not have permission to see the current
     // credential. Populate it into the menu to allow them to save the form
     // without making any changes.
     $current_phid = $this->getValue();
     if (phutil_nonempty_string($current_phid) &&
         empty($options_map[$current_phid])) {
       $viewer = $this->getViewer();
 
       $current_name = null;
       try {
         $user_credential = id(new PassphraseCredentialQuery())
           ->setViewer($viewer)
           ->withPHIDs(array($current_phid))
           ->executeOne();
 
         if ($user_credential) {
           $current_name = pht(
             '%s %s',
             $user_credential->getMonogram(),
             $user_credential->getName());
         }
       } catch (PhabricatorPolicyException $policy_exception) {
         // Pull the credential with the omnipotent viewer so we can look up
         // the ID and provide the monogram.
         $omnipotent_credential = id(new PassphraseCredentialQuery())
           ->setViewer(PhabricatorUser::getOmnipotentUser())
           ->withPHIDs(array($current_phid))
           ->executeOne();
         if ($omnipotent_credential) {
           $current_name = pht(
             '%s (Restricted Credential)',
             $omnipotent_credential->getMonogram());
         }
       }
 
       if ($current_name === null) {
         $current_name = pht(
           'Invalid Credential ("%s")',
           $current_phid);
       }
 
       $options_map = array(
         $current_phid => $current_name,
       ) + $options_map;
     }
 
 
     $disabled = $this->getDisabled();
     if ($this->allowNull) {
       $options_map = array('' => pht('(No Credentials)')) + $options_map;
     } else {
       if (!$options_map) {
         $options_map[''] = pht('(No Existing Credentials)');
         $disabled = true;
       }
     }
 
     Javelin::initBehavior('passphrase-credential-control');
 
     $options = AphrontFormSelectControl::renderSelectTag(
       $this->getValue(),
       $options_map,
       array(
         'id' => $this->getControlID(),
         'name' => $this->getName(),
         'disabled' => $disabled ? 'disabled' : null,
         'sigil' => 'passphrase-credential-select',
       ));
 
     if ($this->credentialType) {
       $button = javelin_tag(
         'a',
         array(
           'href' => '#',
           'class' => 'button button-grey mll',
           'sigil' => 'passphrase-credential-add',
           'mustcapture' => true,
           'style' => 'height: 20px;', // move aphront-form to tables
         ),
         pht('Add New Credential'));
     } else {
       $button = null;
     }
 
     return javelin_tag(
       'div',
       array(
         'sigil' => 'passphrase-credential-control',
         'meta' => array(
           'type' => $this->getCredentialType(),
           'username' => $this->defaultUsername,
           'allowNull' => $this->allowNull,
         ),
       ),
       array(
         $options,
         $button,
       ));
   }
 
   /**
    * Verify that a given actor has permission to use all of the credentials
    * in a list of credential transactions.
    *
    * In general, the rule here is:
    *
    *   - If you're editing an object and it uses a credential you can't use,
    *     that's fine as long as you don't change the credential.
    *   - If you do change the credential, the new credential must be one you
    *     can use.
    *
-   * @param PhabricatorUser The acting user.
-   * @param list<PhabricatorApplicationTransaction> List of credential altering
-   *        transactions.
+   * @param PhabricatorUser $actor The acting user.
+   * @param list<PhabricatorApplicationTransaction> $xactions List of
+   *        credential altering transactions.
    * @return bool True if the transactions are valid.
    */
   public static function validateTransactions(
     PhabricatorUser $actor,
     array $xactions) {
 
     $new_phids = array();
     foreach ($xactions as $xaction) {
       $new = $xaction->getNewValue();
       if (!$new) {
         // Removing a credential, so this is OK.
         continue;
       }
 
       $old = $xaction->getOldValue();
       if ($old == $new) {
         // This is a no-op transaction, so this is also OK.
         continue;
       }
 
       // Otherwise, we need to check this credential.
       $new_phids[] = $new;
     }
 
     if (!$new_phids) {
       // No new credentials being set, so this is fine.
       return true;
     }
 
     $usable_credentials = id(new PassphraseCredentialQuery())
       ->setViewer($actor)
       ->withPHIDs($new_phids)
       ->execute();
     $usable_credentials = mpull($usable_credentials, null, 'getPHID');
 
     foreach ($new_phids as $phid) {
       if (empty($usable_credentials[$phid])) {
         return false;
       }
     }
 
     return true;
   }
 
 
 }
diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php
index 82f1affe82..0e1e2560ac 100644
--- a/src/applications/people/storage/PhabricatorUser.php
+++ b/src/applications/people/storage/PhabricatorUser.php
@@ -1,1483 +1,1483 @@
 <?php
 
 /**
  * @task availability Availability
  * @task image-cache Profile Image Cache
  * @task factors Multi-Factor Authentication
  * @task handles Managing Handles
  * @task settings Settings
  * @task cache User Cache
  */
 final class PhabricatorUser
   extends PhabricatorUserDAO
   implements
     PhutilPerson,
     PhabricatorPolicyInterface,
     PhabricatorCustomFieldInterface,
     PhabricatorDestructibleInterface,
     PhabricatorSSHPublicKeyInterface,
     PhabricatorFlaggableInterface,
     PhabricatorApplicationTransactionInterface,
     PhabricatorFulltextInterface,
     PhabricatorFerretInterface,
     PhabricatorConduitResultInterface,
     PhabricatorAuthPasswordHashInterface {
 
   const SESSION_TABLE = 'phabricator_session';
   const NAMETOKEN_TABLE = 'user_nametoken';
   const MAXIMUM_USERNAME_LENGTH = 64;
 
   protected $userName;
   protected $realName;
   protected $profileImagePHID;
   protected $defaultProfileImagePHID;
   protected $defaultProfileImageVersion;
   protected $availabilityCache;
   protected $availabilityCacheTTL;
 
   protected $conduitCertificate;
 
   protected $isSystemAgent = 0;
   protected $isMailingList = 0;
   protected $isAdmin = 0;
   protected $isDisabled = 0;
   protected $isEmailVerified = 0;
   protected $isApproved = 0;
   protected $isEnrolledInMultiFactor = 0;
 
   protected $accountSecret;
 
   private $profile = null;
   private $availability = self::ATTACHABLE;
   private $preferences = null;
   private $omnipotent = false;
   private $customFields = self::ATTACHABLE;
   private $badgePHIDs = self::ATTACHABLE;
 
   private $alternateCSRFString = self::ATTACHABLE;
   private $session = self::ATTACHABLE;
   private $rawCacheData = array();
   private $usableCacheData = array();
 
   private $handlePool;
   private $csrfSalt;
 
   private $settingCacheKeys = array();
   private $settingCache = array();
   private $allowInlineCacheGeneration;
   private $conduitClusterToken = self::ATTACHABLE;
 
   protected function readField($field) {
     switch ($field) {
       // Make sure these return booleans.
       case 'isAdmin':
         return (bool)$this->isAdmin;
       case 'isDisabled':
         return (bool)$this->isDisabled;
       case 'isSystemAgent':
         return (bool)$this->isSystemAgent;
       case 'isMailingList':
         return (bool)$this->isMailingList;
       case 'isEmailVerified':
         return (bool)$this->isEmailVerified;
       case 'isApproved':
         return (bool)$this->isApproved;
       default:
         return parent::readField($field);
     }
   }
 
 
   /**
    * Is this a live account which has passed required approvals? Returns true
    * if this is an enabled, verified (if required), approved (if required)
    * account, and false otherwise.
    *
    * @return bool True if this is a standard, usable account.
    */
   public function isUserActivated() {
     if (!$this->isLoggedIn()) {
       return false;
     }
 
     if ($this->isOmnipotent()) {
       return true;
     }
 
     if ($this->getIsDisabled()) {
       return false;
     }
 
     if (!$this->getIsApproved()) {
       return false;
     }
 
     if (PhabricatorUserEmail::isEmailVerificationRequired()) {
       if (!$this->getIsEmailVerified()) {
         return false;
       }
     }
 
     return true;
   }
 
 
   /**
    * Is this a user who we can reasonably expect to respond to requests?
    *
    * This is used to provide a grey "disabled/unresponsive" dot cue when
    * rendering handles and tags, so it isn't a surprise if you get ignored
    * when you ask things of users who will not receive notifications or could
    * not respond to them (because they are disabled, unapproved, do not have
    * verified email addresses, etc).
    *
    * @return bool True if this user can receive and respond to requests from
    *   other humans.
    */
   public function isResponsive() {
     if (!$this->isUserActivated()) {
       return false;
     }
 
     if (!$this->getIsEmailVerified()) {
       return false;
     }
 
     return true;
   }
 
 
   public function canEstablishWebSessions() {
     if ($this->getIsMailingList()) {
       return false;
     }
 
     if ($this->getIsSystemAgent()) {
       return false;
     }
 
     return true;
   }
 
   public function canEstablishAPISessions() {
     if ($this->getIsDisabled()) {
       return false;
     }
 
     // Intracluster requests are permitted even if the user is logged out:
     // in particular, public users are allowed to issue intracluster requests
     // when browsing Diffusion.
     if (PhabricatorEnv::isClusterRemoteAddress()) {
       if (!$this->isLoggedIn()) {
         return true;
       }
     }
 
     if (!$this->isUserActivated()) {
       return false;
     }
 
     if ($this->getIsMailingList()) {
       return false;
     }
 
     return true;
   }
 
   public function canEstablishSSHSessions() {
     if (!$this->isUserActivated()) {
       return false;
     }
 
     if ($this->getIsMailingList()) {
       return false;
     }
 
     return true;
   }
 
   /**
    * Returns `true` if this is a standard user who is logged in. Returns `false`
    * for logged out, anonymous, or external users.
    *
    * @return bool `true` if the user is a standard user who is logged in with
    *              a normal session.
    */
   public function getIsStandardUser() {
     $type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
     return $this->getPHID() && (phid_get_type($this->getPHID()) == $type_user);
   }
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_COLUMN_SCHEMA => array(
         'userName' => 'sort64',
         'realName' => 'text128',
         'profileImagePHID' => 'phid?',
         'conduitCertificate' => 'text255',
         'isSystemAgent' => 'bool',
         'isMailingList' => 'bool',
         'isDisabled' => 'bool',
         'isAdmin' => 'bool',
         'isEmailVerified' => 'uint32',
         'isApproved' => 'uint32',
         'accountSecret' => 'bytes64',
         'isEnrolledInMultiFactor' => 'bool',
         'availabilityCache' => 'text255?',
         'availabilityCacheTTL' => 'uint32?',
         'defaultProfileImagePHID' => 'phid?',
         'defaultProfileImageVersion' => 'text64?',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_phid' => null,
         'phid' => array(
           'columns' => array('phid'),
           'unique' => true,
         ),
         'userName' => array(
           'columns' => array('userName'),
           'unique' => true,
         ),
         'realName' => array(
           'columns' => array('realName'),
         ),
         'key_approved' => array(
           'columns' => array('isApproved'),
         ),
       ),
       self::CONFIG_NO_MUTATE => array(
         'availabilityCache' => true,
         'availabilityCacheTTL' => true,
       ),
     ) + parent::getConfiguration();
   }
 
   public function generatePHID() {
     return PhabricatorPHID::generateNewPHID(
       PhabricatorPeopleUserPHIDType::TYPECONST);
   }
 
   public function getMonogram() {
     return '@'.$this->getUsername();
   }
 
   public function isLoggedIn() {
     return !($this->getPHID() === null);
   }
 
   public function saveWithoutIndex() {
     return parent::save();
   }
 
   public function save() {
     if (!$this->getConduitCertificate()) {
       $this->setConduitCertificate($this->generateConduitCertificate());
     }
 
     $secret = $this->getAccountSecret();
     if (($secret === null) || !strlen($secret)) {
       $this->setAccountSecret(Filesystem::readRandomCharacters(64));
     }
 
     $result = $this->saveWithoutIndex();
 
     if ($this->profile) {
       $this->profile->save();
     }
 
     $this->updateNameTokens();
 
     PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID());
 
     return $result;
   }
 
   public function attachSession(PhabricatorAuthSession $session) {
     $this->session = $session;
     return $this;
   }
 
   public function getSession() {
     return $this->assertAttached($this->session);
   }
 
   public function hasSession() {
     return ($this->session !== self::ATTACHABLE);
   }
 
   public function hasHighSecuritySession() {
     if (!$this->hasSession()) {
       return false;
     }
 
     return $this->getSession()->isHighSecuritySession();
   }
 
   private function generateConduitCertificate() {
     return Filesystem::readRandomCharacters(255);
   }
 
   const EMAIL_CYCLE_FREQUENCY = 86400;
   const EMAIL_TOKEN_LENGTH    = 24;
 
   /**
    * This function removes the blurb from a profile.
    * This is an incredibly broad hammer to handle some spam on the upstream,
    * which will be refined later.
    *
    * @return void
    */
   private function cleanUpProfile() {
     $this->profile->setBlurb('');
   }
 
   public function getUserProfile() {
     return $this->assertAttached($this->profile);
   }
 
   public function attachUserProfile(PhabricatorUserProfile $profile) {
     $this->profile = $profile;
 
     if ($this->isDisabled) {
       $this->cleanUpProfile();
     }
 
     return $this;
   }
 
   public function loadUserProfile() {
     if ($this->profile) {
       return $this->profile;
     }
 
     $profile_dao = new PhabricatorUserProfile();
     $this->profile = $profile_dao->loadOneWhere('userPHID = %s',
       $this->getPHID());
 
     if (!$this->profile) {
       $this->profile = PhabricatorUserProfile::initializeNewProfile($this);
     }
 
     if ($this->isDisabled) {
       $this->cleanUpProfile();
     }
 
     return $this->profile;
   }
 
   public function loadPrimaryEmailAddress() {
     $email = $this->loadPrimaryEmail();
     if (!$email) {
       throw new Exception(pht('User has no primary email address!'));
     }
     return $email->getAddress();
   }
 
   public function loadPrimaryEmail() {
     return id(new PhabricatorUserEmail())->loadOneWhere(
       'userPHID = %s AND isPrimary = 1',
       $this->getPHID());
   }
 
 
 /* -(  Settings  )----------------------------------------------------------- */
 
 
   public function getUserSetting($key) {
     // NOTE: We store available keys and cached values separately to make it
     // faster to check for `null` in the cache, which is common.
     if (isset($this->settingCacheKeys[$key])) {
       return $this->settingCache[$key];
     }
 
     $settings_key = PhabricatorUserPreferencesCacheType::KEY_PREFERENCES;
     if ($this->getPHID()) {
       $settings = $this->requireCacheData($settings_key);
     } else {
       $settings = $this->loadGlobalSettings();
     }
 
     if (array_key_exists($key, $settings)) {
       $value = $settings[$key];
       return $this->writeUserSettingCache($key, $value);
     }
 
     $cache = PhabricatorCaches::getRuntimeCache();
     $cache_key = "settings.defaults({$key})";
     $cache_map = $cache->getKeys(array($cache_key));
 
     if ($cache_map) {
       $value = $cache_map[$cache_key];
     } else {
       $defaults = PhabricatorSetting::getAllSettings();
       if (isset($defaults[$key])) {
         $value = id(clone $defaults[$key])
           ->setViewer($this)
           ->getSettingDefaultValue();
       } else {
         $value = null;
       }
 
       $cache->setKey($cache_key, $value);
     }
 
     return $this->writeUserSettingCache($key, $value);
   }
 
 
   /**
    * Test if a given setting is set to a particular value.
    *
-   * @param const Setting key.
-   * @param wild Value to compare.
+   * @param const $key Setting key.
+   * @param wild $value Value to compare.
    * @return bool True if the setting has the specified value.
    * @task settings
    */
   public function compareUserSetting($key, $value) {
     $actual = $this->getUserSetting($key);
     return ($actual == $value);
   }
 
   private function writeUserSettingCache($key, $value) {
     $this->settingCacheKeys[$key] = true;
     $this->settingCache[$key] = $value;
     return $value;
   }
 
   public function getTranslation() {
     return $this->getUserSetting(PhabricatorTranslationSetting::SETTINGKEY);
   }
 
   public function getTimezoneIdentifier() {
     return $this->getUserSetting(PhabricatorTimezoneSetting::SETTINGKEY);
   }
 
   public static function getGlobalSettingsCacheKey() {
     return 'user.settings.globals.v1';
   }
 
   private function loadGlobalSettings() {
     $cache_key = self::getGlobalSettingsCacheKey();
     $cache = PhabricatorCaches::getMutableStructureCache();
 
     $settings = $cache->getKey($cache_key);
     if (!$settings) {
       $preferences = PhabricatorUserPreferences::loadGlobalPreferences($this);
       $settings = $preferences->getPreferences();
       $cache->setKey($cache_key, $settings);
     }
 
     return $settings;
   }
 
 
   /**
    * Override the user's timezone identifier.
    *
    * This is primarily useful for unit tests.
    *
-   * @param string New timezone identifier.
+   * @param string $identifier New timezone identifier.
    * @return this
    * @task settings
    */
   public function overrideTimezoneIdentifier($identifier) {
     $timezone_key = PhabricatorTimezoneSetting::SETTINGKEY;
     $this->settingCacheKeys[$timezone_key] = true;
     $this->settingCache[$timezone_key] = $identifier;
     return $this;
   }
 
   public function getGender() {
     return $this->getUserSetting(PhabricatorPronounSetting::SETTINGKEY);
   }
 
   /**
    * Populate the nametoken table, which used to fetch typeahead results. When
    * a user types "linc", we want to match "Abraham Lincoln" from on-demand
    * typeahead sources. To do this, we need a separate table of name fragments.
    */
   public function updateNameTokens() {
     $table  = self::NAMETOKEN_TABLE;
     $conn_w = $this->establishConnection('w');
 
     $tokens = PhabricatorTypeaheadDatasource::tokenizeString(
       $this->getUserName().' '.$this->getRealName());
 
     $sql = array();
     foreach ($tokens as $token) {
       $sql[] = qsprintf(
         $conn_w,
         '(%d, %s)',
         $this->getID(),
         $token);
     }
 
     queryfx(
       $conn_w,
       'DELETE FROM %T WHERE userID = %d',
       $table,
       $this->getID());
     if ($sql) {
       queryfx(
         $conn_w,
         'INSERT INTO %T (userID, token) VALUES %LQ',
         $table,
         $sql);
     }
   }
 
   public static function describeValidUsername() {
     return pht(
       'Usernames must contain only numbers, letters, period, underscore, and '.
       'hyphen, and can not end with a period. They must have no more than %d '.
       'characters.',
       new PhutilNumber(self::MAXIMUM_USERNAME_LENGTH));
   }
 
   public static function validateUsername($username) {
     // NOTE: If you update this, make sure to update:
     //
     //  - Remarkup rule for @mentions.
     //  - Routing rule for "/p/username/".
     //  - Unit tests, obviously.
     //  - describeValidUsername() method, above.
 
     if (strlen($username) > self::MAXIMUM_USERNAME_LENGTH) {
       return false;
     }
 
     return (bool)preg_match('/^[a-zA-Z0-9._-]*[a-zA-Z0-9_-]\z/', $username);
   }
 
   public static function getDefaultProfileImageURI() {
     return celerity_get_resource_uri('/rsrc/image/avatar.png');
   }
 
   public function getProfileImageURI() {
     $uri_key = PhabricatorUserProfileImageCacheType::KEY_URI;
     return $this->requireCacheData($uri_key);
   }
 
   public function getUnreadNotificationCount() {
     $notification_key = PhabricatorUserNotificationCountCacheType::KEY_COUNT;
     return $this->requireCacheData($notification_key);
   }
 
   public function getUnreadMessageCount() {
     $message_key = PhabricatorUserMessageCountCacheType::KEY_COUNT;
     return $this->requireCacheData($message_key);
   }
 
   public function getRecentBadgeAwards() {
     $badges_key = PhabricatorUserBadgesCacheType::KEY_BADGES;
     return $this->requireCacheData($badges_key);
   }
 
   public function getFullName() {
     if (strlen($this->getRealName())) {
       return $this->getUsername().' ('.$this->getRealName().')';
     } else {
       return $this->getUsername();
     }
   }
 
   public function getTimeZone() {
     return new DateTimeZone($this->getTimezoneIdentifier());
   }
 
   public function getTimeZoneOffset() {
     $timezone = $this->getTimeZone();
     $now = new DateTime('@'.PhabricatorTime::getNow());
     $offset = $timezone->getOffset($now);
 
     // Javascript offsets are in minutes and have the opposite sign.
     $offset = -(int)($offset / 60);
 
     return $offset;
   }
 
   public function getTimeZoneOffsetInHours() {
     $offset = $this->getTimeZoneOffset();
     $offset = (int)round($offset / 60);
     $offset = -$offset;
 
     return $offset;
   }
 
   public function formatShortDateTime($when, $now = null) {
     if ($now === null) {
       $now = PhabricatorTime::getNow();
     }
 
     try {
       $when = new DateTime('@'.$when);
       $now = new DateTime('@'.$now);
     } catch (Exception $ex) {
       return null;
     }
 
     $zone = $this->getTimeZone();
 
     $when->setTimeZone($zone);
     $now->setTimeZone($zone);
 
     if ($when->format('Y') !== $now->format('Y')) {
       // Different year, so show "Feb 31 2075".
       $format = 'M j Y';
     } else if ($when->format('Ymd') !== $now->format('Ymd')) {
       // Same year but different month and day, so show "Feb 31".
       $format = 'M j';
     } else {
       // Same year, month and day so show a time of day.
       $pref_time = PhabricatorTimeFormatSetting::SETTINGKEY;
       $format = $this->getUserSetting($pref_time);
     }
 
     return $when->format($format);
   }
 
   public function __toString() {
     return $this->getUsername();
   }
 
   public static function loadOneWithEmailAddress($address) {
     $email = id(new PhabricatorUserEmail())->loadOneWhere(
       'address = %s',
       $address);
     if (!$email) {
       return null;
     }
     return id(new PhabricatorUser())->loadOneWhere(
       'phid = %s',
       $email->getUserPHID());
   }
 
   public function getDefaultSpacePHID() {
     // TODO: We might let the user switch which space they're "in" later on;
     // for now just use the global space if one exists.
 
     // If the viewer has access to the default space, use that.
     $spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces($this);
     foreach ($spaces as $space) {
       if ($space->getIsDefaultNamespace()) {
         return $space->getPHID();
       }
     }
 
     // Otherwise, use the space with the lowest ID that they have access to.
     // This just tends to keep the default stable and predictable over time,
     // so adding a new space won't change behavior for users.
     if ($spaces) {
       $spaces = msort($spaces, 'getID');
       return head($spaces)->getPHID();
     }
 
     return null;
   }
 
 
   public function hasConduitClusterToken() {
     return ($this->conduitClusterToken !== self::ATTACHABLE);
   }
 
   public function attachConduitClusterToken(PhabricatorConduitToken $token) {
     $this->conduitClusterToken = $token;
     return $this;
   }
 
   public function getConduitClusterToken() {
     return $this->assertAttached($this->conduitClusterToken);
   }
 
 
 /* -(  Availability  )------------------------------------------------------- */
 
 
   /**
    * @task availability
    */
   public function attachAvailability(array $availability) {
     $this->availability = $availability;
     return $this;
   }
 
 
   /**
    * Get the timestamp the user is away until, if they are currently away.
    *
    * @return int|null Epoch timestamp, or `null` if the user is not away.
    * @task availability
    */
   public function getAwayUntil() {
     $availability = $this->availability;
 
     $this->assertAttached($availability);
     if (!$availability) {
       return null;
     }
 
     return idx($availability, 'until');
   }
 
 
   public function getDisplayAvailability() {
     $availability = $this->availability;
 
     $this->assertAttached($availability);
     if (!$availability) {
       return null;
     }
 
     $busy = PhabricatorCalendarEventInvitee::AVAILABILITY_BUSY;
 
     return idx($availability, 'availability', $busy);
   }
 
 
   public function getAvailabilityEventPHID() {
     $availability = $this->availability;
 
     $this->assertAttached($availability);
     if (!$availability) {
       return null;
     }
 
     return idx($availability, 'eventPHID');
   }
 
 
   /**
    * Get cached availability, if present.
    *
    * @return wild|null Cache data, or null if no cache is available.
    * @task availability
    */
   public function getAvailabilityCache() {
     $now = PhabricatorTime::getNow();
     if ($this->availabilityCacheTTL <= $now) {
       return null;
     }
 
     try {
       return phutil_json_decode($this->availabilityCache);
     } catch (Exception $ex) {
       return null;
     }
   }
 
 
   /**
    * Write to the availability cache.
    *
-   * @param wild Availability cache data.
-   * @param int|null Cache TTL.
+   * @param wild $availability Availability cache data.
+   * @param int|null $ttl Cache TTL.
    * @return this
    * @task availability
    */
   public function writeAvailabilityCache(array $availability, $ttl) {
     if (PhabricatorEnv::isReadOnly()) {
       return $this;
     }
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
     queryfx(
       $this->establishConnection('w'),
       'UPDATE %T SET availabilityCache = %s, availabilityCacheTTL = %nd
         WHERE id = %d',
       $this->getTableName(),
       phutil_json_encode($availability),
       $ttl,
       $this->getID());
     unset($unguarded);
 
     return $this;
   }
 
 
 /* -(  Multi-Factor Authentication  )---------------------------------------- */
 
 
   /**
    * Update the flag storing this user's enrollment in multi-factor auth.
    *
    * With certain settings, we need to check if a user has MFA on every page,
    * so we cache MFA enrollment on the user object for performance. Calling this
    * method synchronizes the cache by examining enrollment records. After
    * updating the cache, use @{method:getIsEnrolledInMultiFactor} to check if
    * the user is enrolled.
    *
    * This method should be called after any changes are made to a given user's
    * multi-factor configuration.
    *
    * @return void
    * @task factors
    */
   public function updateMultiFactorEnrollment() {
     $factors = id(new PhabricatorAuthFactorConfigQuery())
       ->setViewer($this)
       ->withUserPHIDs(array($this->getPHID()))
       ->withFactorProviderStatuses(
         array(
           PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
           PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
         ))
       ->execute();
 
     $enrolled = count($factors) ? 1 : 0;
     if ($enrolled !== $this->isEnrolledInMultiFactor) {
       $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
         queryfx(
           $this->establishConnection('w'),
           'UPDATE %T SET isEnrolledInMultiFactor = %d WHERE id = %d',
           $this->getTableName(),
           $enrolled,
           $this->getID());
       unset($unguarded);
 
       $this->isEnrolledInMultiFactor = $enrolled;
     }
   }
 
 
   /**
    * Check if the user is enrolled in multi-factor authentication.
    *
    * Enrolled users have one or more multi-factor authentication sources
    * attached to their account. For performance, this value is cached. You
    * can use @{method:updateMultiFactorEnrollment} to update the cache.
    *
    * @return bool True if the user is enrolled.
    * @task factors
    */
   public function getIsEnrolledInMultiFactor() {
     return $this->isEnrolledInMultiFactor;
   }
 
 
 /* -(  Omnipotence  )-------------------------------------------------------- */
 
 
   /**
    * Returns true if this user is omnipotent. Omnipotent users bypass all policy
    * checks.
    *
    * @return bool True if the user bypasses policy checks.
    */
   public function isOmnipotent() {
     return $this->omnipotent;
   }
 
 
   /**
    * Get an omnipotent user object for use in contexts where there is no acting
    * user, notably daemons.
    *
    * @return PhabricatorUser An omnipotent user.
    */
   public static function getOmnipotentUser() {
     static $user = null;
     if (!$user) {
       $user = new PhabricatorUser();
       $user->omnipotent = true;
       $user->makeEphemeral();
     }
     return $user;
   }
 
 
   /**
    * Get a scalar string identifying this user.
    *
    * This is similar to using the PHID, but distinguishes between omnipotent
    * and public users explicitly. This allows safe construction of cache keys
    * or cache buckets which do not conflate public and omnipotent users.
    *
    * @return string Scalar identifier.
    */
   public function getCacheFragment() {
     if ($this->isOmnipotent()) {
       return 'u.omnipotent';
     }
 
     $phid = $this->getPHID();
     if ($phid) {
       return 'u.'.$phid;
     }
 
     return 'u.public';
   }
 
 
 /* -(  Managing Handles  )--------------------------------------------------- */
 
 
   /**
    * Get a @{class:PhabricatorHandleList} which benefits from this viewer's
    * internal handle pool.
    *
-   * @param list<phid> List of PHIDs to load.
+   * @param list<phid> $phids List of PHIDs to load.
    * @return PhabricatorHandleList Handle list object.
    * @task handle
    */
   public function loadHandles(array $phids) {
     if ($this->handlePool === null) {
       $this->handlePool = id(new PhabricatorHandlePool())
         ->setViewer($this);
     }
 
     return $this->handlePool->newHandleList($phids);
   }
 
 
   /**
    * Get a @{class:PHUIHandleView} for a single handle.
    *
    * This benefits from the viewer's internal handle pool.
    *
-   * @param phid PHID to render a handle for.
+   * @param phid $phid PHID to render a handle for.
    * @return PHUIHandleView View of the handle.
    * @task handle
    */
   public function renderHandle($phid) {
     return $this->loadHandles(array($phid))->renderHandle($phid);
   }
 
 
   /**
    * Get a @{class:PHUIHandleListView} for a list of handles.
    *
    * This benefits from the viewer's internal handle pool.
    *
-   * @param list<phid> List of PHIDs to render.
+   * @param list<phid> $phids List of PHIDs to render.
    * @return PHUIHandleListView View of the handles.
    * @task handle
    */
   public function renderHandleList(array $phids) {
     return $this->loadHandles($phids)->renderList();
   }
 
   public function attachBadgePHIDs(array $phids) {
     $this->badgePHIDs = $phids;
     return $this;
   }
 
   public function getBadgePHIDs() {
     return $this->assertAttached($this->badgePHIDs);
   }
 
 /* -(  CSRF  )--------------------------------------------------------------- */
 
 
   public function getCSRFToken() {
     if ($this->isOmnipotent()) {
       // We may end up here when called from the daemons. The omnipotent user
       // has no meaningful CSRF token, so just return `null`.
       return null;
     }
 
     return $this->newCSRFEngine()
       ->newToken();
   }
 
   public function validateCSRFToken($token) {
     return $this->newCSRFengine()
       ->isValidToken($token);
   }
 
   public function getAlternateCSRFString() {
     return $this->assertAttached($this->alternateCSRFString);
   }
 
   public function attachAlternateCSRFString($string) {
     $this->alternateCSRFString = $string;
     return $this;
   }
 
   private function newCSRFEngine() {
     if ($this->getPHID()) {
       $vec = $this->getPHID().$this->getAccountSecret();
     } else {
       $vec = $this->getAlternateCSRFString();
     }
 
     if ($this->hasSession()) {
       $vec = $vec.$this->getSession()->getSessionKey();
     }
 
     $engine = new PhabricatorAuthCSRFEngine();
 
     if ($this->csrfSalt === null) {
       $this->csrfSalt = $engine->newSalt();
     }
 
     $engine
       ->setSalt($this->csrfSalt)
       ->setSecret(new PhutilOpaqueEnvelope($vec));
 
     return $engine;
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         return PhabricatorPolicies::POLICY_PUBLIC;
       case PhabricatorPolicyCapability::CAN_EDIT:
         if ($this->getIsSystemAgent() || $this->getIsMailingList()) {
           return PhabricatorPolicies::POLICY_ADMIN;
         } else {
           return PhabricatorPolicies::POLICY_NOONE;
         }
     }
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return $this->getPHID() && ($viewer->getPHID() === $this->getPHID());
   }
 
   public function describeAutomaticCapability($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_EDIT:
         return pht('Only you can edit your information.');
       default:
         return null;
     }
   }
 
 
 /* -(  PhabricatorCustomFieldInterface  )------------------------------------ */
 
 
   public function getCustomFieldSpecificationForRole($role) {
     return PhabricatorEnv::getEnvConfig('user.fields');
   }
 
   public function getCustomFieldBaseClass() {
     return 'PhabricatorUserCustomField';
   }
 
   public function getCustomFields() {
     return $this->assertAttached($this->customFields);
   }
 
   public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) {
     $this->customFields = $fields;
     return $this;
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
 
     $viewer = $engine->getViewer();
 
     $this->openTransaction();
       $this->delete();
 
       $externals = id(new PhabricatorExternalAccountQuery())
         ->setViewer($viewer)
         ->withUserPHIDs(array($this->getPHID()))
         ->newIterator();
       foreach ($externals as $external) {
         $engine->destroyObject($external);
       }
 
       $prefs = id(new PhabricatorUserPreferencesQuery())
         ->setViewer($viewer)
         ->withUsers(array($this))
         ->execute();
       foreach ($prefs as $pref) {
         $engine->destroyObject($pref);
       }
 
       $profiles = id(new PhabricatorUserProfile())->loadAllWhere(
         'userPHID = %s',
         $this->getPHID());
       foreach ($profiles as $profile) {
         $profile->delete();
       }
 
       $keys = id(new PhabricatorAuthSSHKeyQuery())
         ->setViewer($viewer)
         ->withObjectPHIDs(array($this->getPHID()))
         ->execute();
       foreach ($keys as $key) {
         $engine->destroyObject($key);
       }
 
       $emails = id(new PhabricatorUserEmail())->loadAllWhere(
         'userPHID = %s',
         $this->getPHID());
       foreach ($emails as $email) {
         $engine->destroyObject($email);
       }
 
       $sessions = id(new PhabricatorAuthSession())->loadAllWhere(
         'userPHID = %s',
         $this->getPHID());
       foreach ($sessions as $session) {
         $session->delete();
       }
 
       $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere(
         'userPHID = %s',
         $this->getPHID());
       foreach ($factors as $factor) {
         $factor->delete();
       }
 
     $this->saveTransaction();
   }
 
 
 /* -(  PhabricatorSSHPublicKeyInterface  )----------------------------------- */
 
 
   public function getSSHPublicKeyManagementURI(PhabricatorUser $viewer) {
     if ($viewer->getPHID() == $this->getPHID()) {
       // If the viewer is managing their own keys, take them to the normal
       // panel.
       return '/settings/panel/ssh/';
     } else {
       // Otherwise, take them to the administrative panel for this user.
       return '/settings/user/'.$this->getUsername().'/page/ssh/';
     }
   }
 
   public function getSSHKeyDefaultName() {
     return 'id_rsa_phorge';
   }
 
   public function getSSHKeyNotifyPHIDs() {
     return array(
       $this->getPHID(),
     );
   }
 
 
 /* -(  PhabricatorApplicationTransactionInterface  )------------------------- */
 
 
   public function getApplicationTransactionEditor() {
     return new PhabricatorUserTransactionEditor();
   }
 
   public function getApplicationTransactionTemplate() {
     return new PhabricatorUserTransaction();
   }
 
 
 /* -(  PhabricatorFulltextInterface  )--------------------------------------- */
 
 
   public function newFulltextEngine() {
     return new PhabricatorUserFulltextEngine();
   }
 
 
 /* -(  PhabricatorFerretInterface  )----------------------------------------- */
 
 
   public function newFerretEngine() {
     return new PhabricatorUserFerretEngine();
   }
 
 
 /* -(  PhabricatorConduitResultInterface  )---------------------------------- */
 
 
   public function getFieldSpecificationsForConduit() {
     return array(
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('username')
         ->setType('string')
         ->setDescription(pht("The user's username.")),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('realName')
         ->setType('string')
         ->setDescription(pht("The user's real name.")),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('roles')
         ->setType('list<string>')
         ->setDescription(pht('List of account roles.')),
     );
   }
 
   public function getFieldValuesForConduit() {
     $roles = array();
 
     if ($this->getIsDisabled()) {
       $roles[] = 'disabled';
     }
 
     if ($this->getIsSystemAgent()) {
       $roles[] = 'bot';
     }
 
     if ($this->getIsMailingList()) {
       $roles[] = 'list';
     }
 
     if ($this->getIsAdmin()) {
       $roles[] = 'admin';
     }
 
     if ($this->getIsEmailVerified()) {
       $roles[] = 'verified';
     }
 
     if ($this->getIsApproved()) {
       $roles[] = 'approved';
     }
 
     if ($this->isUserActivated()) {
       $roles[] = 'activated';
     }
 
     return array(
       'username' => $this->getUsername(),
       'realName' => $this->getRealName(),
       'roles' => $roles,
     );
   }
 
   public function getConduitSearchAttachments() {
     return array(
       id(new PhabricatorPeopleAvailabilitySearchEngineAttachment())
         ->setAttachmentKey('availability'),
     );
   }
 
 
 /* -(  User Cache  )--------------------------------------------------------- */
 
 
   /**
    * @task cache
    */
   public function attachRawCacheData(array $data) {
     $this->rawCacheData = $data + $this->rawCacheData;
     return $this;
   }
 
   public function setAllowInlineCacheGeneration($allow_cache_generation) {
     $this->allowInlineCacheGeneration = $allow_cache_generation;
     return $this;
   }
 
   /**
    * @task cache
    */
   protected function requireCacheData($key) {
     if (isset($this->usableCacheData[$key])) {
       return $this->usableCacheData[$key];
     }
 
     $type = PhabricatorUserCacheType::requireCacheTypeForKey($key);
 
     if (isset($this->rawCacheData[$key])) {
       $raw_value = $this->rawCacheData[$key];
 
       $usable_value = $type->getValueFromStorage($raw_value);
       $this->usableCacheData[$key] = $usable_value;
 
       return $usable_value;
     }
 
     // By default, we throw if a cache isn't available. This is consistent
     // with the standard `needX()` + `attachX()` + `getX()` interaction.
     if (!$this->allowInlineCacheGeneration) {
       throw new PhabricatorDataNotAttachedException($this);
     }
 
     $user_phid = $this->getPHID();
 
     // Try to read the actual cache before we generate a new value. We can
     // end up here via Conduit, which does not use normal sessions and can
     // not pick up a free cache load during session identification.
     if ($user_phid) {
       $raw_data = PhabricatorUserCache::readCaches(
         $type,
         $key,
         array($user_phid));
       if (array_key_exists($user_phid, $raw_data)) {
         $raw_value = $raw_data[$user_phid];
         $usable_value = $type->getValueFromStorage($raw_value);
         $this->rawCacheData[$key] = $raw_value;
         $this->usableCacheData[$key] = $usable_value;
         return $usable_value;
       }
     }
 
     $usable_value = $type->getDefaultValue();
 
     if ($user_phid) {
       $map = $type->newValueForUsers($key, array($this));
       if (array_key_exists($user_phid, $map)) {
         $raw_value = $map[$user_phid];
         $usable_value = $type->getValueFromStorage($raw_value);
 
         $this->rawCacheData[$key] = $raw_value;
         PhabricatorUserCache::writeCache(
           $type,
           $key,
           $user_phid,
           $raw_value);
       }
     }
 
     $this->usableCacheData[$key] = $usable_value;
 
     return $usable_value;
   }
 
 
   /**
    * @task cache
    */
   public function clearCacheData($key) {
     unset($this->rawCacheData[$key]);
     unset($this->usableCacheData[$key]);
     return $this;
   }
 
 
   public function getCSSValue($variable_key) {
     $preference = PhabricatorAccessibilitySetting::SETTINGKEY;
     $key = $this->getUserSetting($preference);
 
     $postprocessor = CelerityPostprocessor::getPostprocessor($key);
     $variables = $postprocessor->getVariables();
 
     if (!isset($variables[$variable_key])) {
       throw new Exception(
         pht(
           'Unknown CSS variable "%s"!',
           $variable_key));
     }
 
     return $variables[$variable_key];
   }
 
 /* -(  PhabricatorAuthPasswordHashInterface  )------------------------------- */
 
 
   public function newPasswordDigest(
     PhutilOpaqueEnvelope $envelope,
     PhabricatorAuthPassword $password) {
 
     // Before passwords are hashed, they are digested. The goal of digestion
     // is twofold: to reduce the length of very long passwords to something
     // reasonable; and to salt the password in case the best available hasher
     // does not include salt automatically.
 
     // Users may choose arbitrarily long passwords, and attackers may try to
     // attack the system by probing it with very long passwords. When large
     // inputs are passed to hashers -- which are intentionally slow -- it
     // can result in unacceptably long runtimes. The classic attack here is
     // to try to log in with a 64MB password and see if that locks up the
     // machine for the next century. By digesting passwords to a standard
     // length first, the length of the raw input does not impact the runtime
     // of the hashing algorithm.
 
     // Some hashers like bcrypt are self-salting, while other hashers are not.
     // Applying salt while digesting passwords ensures that hashes are salted
     // whether we ultimately select a self-salting hasher or not.
 
     // For legacy compatibility reasons, old VCS and Account password digest
     // algorithms are significantly more complicated than necessary to achieve
     // these goals. This is because they once used a different hashing and
     // salting process. When we upgraded to the modern modular hasher
     // infrastructure, we just bolted it onto the end of the existing pipelines
     // so that upgrading didn't break all users' credentials.
 
     // New implementations can (and, generally, should) safely select the
     // simple HMAC SHA256 digest at the bottom of the function, which does
     // everything that a digest callback should without any needless legacy
     // baggage on top.
 
     if ($password->getLegacyDigestFormat() == 'v1') {
       switch ($password->getPasswordType()) {
         case PhabricatorAuthPassword::PASSWORD_TYPE_VCS:
           // Old VCS passwords use an iterated HMAC SHA1 as a digest algorithm.
           // They originally used this as a hasher, but it became a digest
           // algorithm once hashing was upgraded to include bcrypt.
           $digest = $envelope->openEnvelope();
           $salt = $this->getPHID();
           for ($ii = 0; $ii < 1000; $ii++) {
             $digest = PhabricatorHash::weakDigest($digest, $salt);
           }
           return new PhutilOpaqueEnvelope($digest);
         case PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT:
           // Account passwords previously used this weird mess of salt and did
           // not digest the input to a standard length.
 
           // Beyond this being a weird special case, there are two actual
           // problems with this, although neither are particularly severe:
 
           // First, because we do not normalize the length of passwords, this
           // algorithm may make us vulnerable to DOS attacks where an attacker
           // attempts to use a very long input to slow down hashers.
 
           // Second, because the username is part of the hash algorithm,
           // renaming a user breaks their password. This isn't a huge deal but
           // it's pretty silly. There's no security justification for this
           // behavior, I just didn't think about the implication when I wrote
           // it originally.
 
           $parts = array(
             $this->getUsername(),
             $envelope->openEnvelope(),
             $this->getPHID(),
             $password->getPasswordSalt(),
           );
 
           return new PhutilOpaqueEnvelope(implode('', $parts));
       }
     }
 
     // For passwords which do not have some crazy legacy reason to use some
     // other digest algorithm, HMAC SHA256 is an excellent choice. It satisfies
     // the digest requirements and is simple.
 
     $digest = PhabricatorHash::digestHMACSHA256(
       $envelope->openEnvelope(),
       $password->getPasswordSalt());
 
     return new PhutilOpaqueEnvelope($digest);
   }
 
   public function newPasswordBlocklist(
     PhabricatorUser $viewer,
     PhabricatorAuthPasswordEngine $engine) {
 
     $list = array();
     $list[] = $this->getUsername();
     $list[] = $this->getRealName();
 
     $emails = id(new PhabricatorUserEmail())->loadAllWhere(
       'userPHID = %s',
       $this->getPHID());
     foreach ($emails as $email) {
       $list[] = $email->getAddress();
     }
 
     return $list;
   }
 
 
 }
diff --git a/src/applications/people/storage/PhabricatorUserEmail.php b/src/applications/people/storage/PhabricatorUserEmail.php
index 47c2911601..bf6b487d88 100644
--- a/src/applications/people/storage/PhabricatorUserEmail.php
+++ b/src/applications/people/storage/PhabricatorUserEmail.php
@@ -1,337 +1,337 @@
 <?php
 
 /**
  * @task restrictions   Domain Restrictions
  * @task email          Email About Email
  */
 final class PhabricatorUserEmail
   extends PhabricatorUserDAO
   implements
     PhabricatorDestructibleInterface,
     PhabricatorPolicyInterface {
 
   protected $userPHID;
   protected $address;
   protected $isVerified;
   protected $isPrimary;
   protected $verificationCode;
 
   private $user = self::ATTACHABLE;
 
   const MAX_ADDRESS_LENGTH = 128;
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_COLUMN_SCHEMA => array(
         'address' => 'sort128',
         'isVerified' => 'bool',
         'isPrimary' => 'bool',
         'verificationCode' => 'text64?',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'address' => array(
           'columns' => array('address'),
           'unique' => true,
         ),
         'userPHID' => array(
           'columns' => array('userPHID', 'isPrimary'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function getPHIDType() {
     return PhabricatorPeopleUserEmailPHIDType::TYPECONST;
   }
 
   public function getVerificationURI() {
     return '/emailverify/'.$this->getVerificationCode().'/';
   }
 
   public function save() {
     if (!$this->verificationCode) {
       $this->setVerificationCode(Filesystem::readRandomCharacters(24));
     }
     return parent::save();
   }
 
   public function attachUser(PhabricatorUser $user) {
     $this->user = $user;
     return $this;
   }
 
   public function getUser() {
     return $this->assertAttached($this->user);
   }
 
 
 /* -(  Domain Restrictions  )------------------------------------------------ */
 
 
   /**
    * @task restrictions
    */
   public static function isValidAddress($address) {
     if (strlen($address) > self::MAX_ADDRESS_LENGTH) {
       return false;
     }
 
     // Very roughly validate that this address isn't so mangled that a
     // reasonable piece of code might completely misparse it. In particular,
     // the major risks are:
     //
     //   - `PhutilEmailAddress` needs to be able to extract the domain portion
     //     from it.
     //   - Reasonable mail adapters should be hard-pressed to interpret one
     //     address as several addresses.
     //
     // To this end, we're roughly verifying that there's some normal text, an
     // "@" symbol, and then some more normal text.
 
     $email_regex = '(^[a-z0-9_+.!-]+@[a-z0-9_+:.-]+\z)i';
     if (!preg_match($email_regex, $address)) {
       return false;
     }
 
     return true;
   }
 
 
   /**
    * @task restrictions
    */
   public static function describeValidAddresses() {
     return pht(
       'Email addresses should be in the form "user@domain.com". The maximum '.
       'length of an email address is %s characters.',
       new PhutilNumber(self::MAX_ADDRESS_LENGTH));
   }
 
 
   /**
    * @task restrictions
    */
   public static function isAllowedAddress($address) {
     if (!self::isValidAddress($address)) {
       return false;
     }
 
     $allowed_domains = PhabricatorEnv::getEnvConfig('auth.email-domains');
     if (!$allowed_domains) {
       return true;
     }
 
     $addr_obj = new PhutilEmailAddress($address);
 
     $domain = $addr_obj->getDomainName();
     if (!$domain) {
       return false;
     }
 
     $lower_domain = phutil_utf8_strtolower($domain);
     foreach ($allowed_domains as $allowed_domain) {
       $lower_allowed = phutil_utf8_strtolower($allowed_domain);
       if ($lower_allowed === $lower_domain) {
         return true;
       }
     }
 
     return false;
   }
 
 
   /**
    * @task restrictions
    */
   public static function describeAllowedAddresses() {
     $domains = PhabricatorEnv::getEnvConfig('auth.email-domains');
     if (!$domains) {
       return null;
     }
 
     if (count($domains) == 1) {
       return pht('Email address must be @%s', head($domains));
     } else {
       return pht(
         'Email address must be at one of: %s',
         implode(', ', $domains));
     }
   }
 
 
   /**
    * Check if this install requires email verification.
    *
    * @return bool True if email addresses must be verified.
    *
    * @task restrictions
    */
   public static function isEmailVerificationRequired() {
     // NOTE: Configuring required email domains implies required verification.
     return PhabricatorEnv::getEnvConfig('auth.require-email-verification') ||
            PhabricatorEnv::getEnvConfig('auth.email-domains');
   }
 
 
 /* -(  Email About Email  )-------------------------------------------------- */
 
 
   /**
    * Send a verification email from $user to this address.
    *
-   * @param PhabricatorUser The user sending the verification.
+   * @param PhabricatorUser $user The user sending the verification.
    * @return this
    * @task email
    */
   public function sendVerificationEmail(PhabricatorUser $user) {
     $username = $user->getUsername();
 
     $address = $this->getAddress();
     $link = PhabricatorEnv::getProductionURI($this->getVerificationURI());
 
 
     $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
 
     $signature = null;
     if (!$is_serious) {
       $signature = pht(
         "Get Well Soon,\n%s",
         PlatformSymbols::getPlatformServerName());
     }
 
     $body = sprintf(
       "%s\n\n%s\n\n  %s\n\n%s",
       pht('Hi %s', $username),
       pht(
         'Please verify that you own this email address (%s) by '.
         'clicking this link:',
         $address),
       $link,
       $signature);
 
     id(new PhabricatorMetaMTAMail())
       ->addRawTos(array($address))
       ->setForceDelivery(true)
       ->setSubject(
         pht(
           '[%s] Email Verification',
           PlatformSymbols::getPlatformServerName()))
       ->setBody($body)
       ->setRelatedPHID($user->getPHID())
       ->saveAndSend();
 
     return $this;
   }
 
 
   /**
    * Send a notification email from $user to this address, informing the
    * recipient that this is no longer their account's primary address.
    *
-   * @param PhabricatorUser The user sending the notification.
-   * @param PhabricatorUserEmail New primary email address.
+   * @param PhabricatorUser $user The user sending the notification.
+   * @param PhabricatorUserEmail $new New primary email address.
    * @task email
    */
   public function sendOldPrimaryEmail(
     PhabricatorUser $user,
     PhabricatorUserEmail $new) {
     $username = $user->getUsername();
 
     $old_address = $this->getAddress();
     $new_address = $new->getAddress();
 
     $body = sprintf(
       "%s\n\n%s\n",
       pht('Hi %s', $username),
       pht(
         'This email address (%s) is no longer your primary email address. '.
         'Going forward, all email will be sent to your new primary email '.
         'address (%s).',
         $old_address,
         $new_address));
 
     id(new PhabricatorMetaMTAMail())
       ->addRawTos(array($old_address))
       ->setForceDelivery(true)
       ->setSubject(
         pht(
           '[%s] Primary Address Changed',
           PlatformSymbols::getPlatformServerName()))
       ->setBody($body)
       ->setFrom($user->getPHID())
       ->setRelatedPHID($user->getPHID())
       ->saveAndSend();
   }
 
 
   /**
    * Send a notification email from $user to this address, informing the
    * recipient that this is now their account's new primary email address.
    *
-   * @param PhabricatorUser The user sending the verification.
+   * @param PhabricatorUser $user The user sending the verification.
    * @return this
    * @task email
    */
   public function sendNewPrimaryEmail(PhabricatorUser $user) {
     $username = $user->getUsername();
 
     $new_address = $this->getAddress();
 
     $body = sprintf(
       "%s\n\n%s\n",
       pht('Hi %s', $username),
       pht(
         'This is now your primary email address (%s). Going forward, '.
         'all email will be sent here.',
         $new_address));
 
     id(new PhabricatorMetaMTAMail())
       ->addRawTos(array($new_address))
       ->setForceDelivery(true)
       ->setSubject(
         pht(
           '[%s] Primary Address Changed',
           PlatformSymbols::getPlatformServerName()))
       ->setBody($body)
       ->setFrom($user->getPHID())
       ->setRelatedPHID($user->getPHID())
       ->saveAndSend();
 
     return $this;
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
     $this->delete();
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     $user = $this->getUser();
 
     if ($this->getIsSystemAgent() || $this->getIsMailingList()) {
       return PhabricatorPolicies::POLICY_ADMIN;
     }
 
     return $user->getPHID();
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return false;
   }
 
 }
diff --git a/src/applications/phid/PhabricatorObjectHandle.php b/src/applications/phid/PhabricatorObjectHandle.php
index 577fd05692..6a6c1f19b8 100644
--- a/src/applications/phid/PhabricatorObjectHandle.php
+++ b/src/applications/phid/PhabricatorObjectHandle.php
@@ -1,494 +1,494 @@
 <?php
 
 final class PhabricatorObjectHandle
   extends Phobject
   implements PhabricatorPolicyInterface {
 
   const AVAILABILITY_FULL = 'full';
   const AVAILABILITY_NONE = 'none';
   const AVAILABILITY_NOEMAIL = 'no-email';
   const AVAILABILITY_PARTIAL = 'partial';
   const AVAILABILITY_DISABLED = 'disabled';
 
   const STATUS_OPEN = 'open';
   const STATUS_CLOSED = 'closed';
 
   private $uri;
   private $phid;
   private $type;
   private $name;
   private $fullName;
   private $title;
   private $imageURI;
   private $icon;
   private $tagColor;
   private $timestamp;
   private $status = self::STATUS_OPEN;
   private $availability = self::AVAILABILITY_FULL;
   private $complete;
   private $objectName;
   private $policyFiltered;
   private $subtitle;
   private $tokenIcon;
   private $commandLineObjectName;
   private $mailStampName;
   private $capabilities = array();
 
   public function setIcon($icon) {
     $this->icon = $icon;
     return $this;
   }
 
   public function getIcon() {
     if ($this->getPolicyFiltered()) {
       return 'fa-lock';
     }
 
     if ($this->icon) {
       return $this->icon;
     }
     return $this->getTypeIcon();
   }
 
   public function setSubtitle($subtitle) {
     $this->subtitle = $subtitle;
     return $this;
   }
 
   public function getSubtitle() {
     return $this->subtitle;
   }
 
   public function setTagColor($color) {
     static $colors;
     if (!$colors) {
       $colors = array_fuse(array_keys(PHUITagView::getShadeMap()));
     }
 
     if (isset($colors[$color])) {
       $this->tagColor = $color;
     }
 
     return $this;
   }
 
   public function getTagColor() {
     if ($this->getPolicyFiltered()) {
       return 'disabled';
     }
 
     if ($this->tagColor) {
       return $this->tagColor;
     }
 
     return 'blue';
   }
 
   public function getIconColor() {
     if ($this->tagColor) {
       return $this->tagColor;
     }
     return null;
   }
 
   public function setTokenIcon($icon) {
     $this->tokenIcon = $icon;
     return $this;
   }
 
   public function getTokenIcon() {
     if ($this->tokenIcon !== null) {
       return $this->tokenIcon;
     }
 
     return $this->getIcon();
   }
 
   public function getTypeIcon() {
     if ($this->getPHIDType()) {
       return $this->getPHIDType()->getTypeIcon();
     }
     return null;
   }
 
   public function setPolicyFiltered($policy_filered) {
     $this->policyFiltered = $policy_filered;
     return $this;
   }
 
   public function getPolicyFiltered() {
     return $this->policyFiltered;
   }
 
   public function setObjectName($object_name) {
     $this->objectName = $object_name;
     return $this;
   }
 
   public function getObjectName() {
     if (!$this->objectName) {
       return $this->getName();
     }
     return $this->objectName;
   }
 
   public function setMailStampName($mail_stamp_name) {
     $this->mailStampName = $mail_stamp_name;
     return $this;
   }
 
   public function getMailStampName() {
     return $this->mailStampName;
   }
 
   public function setURI($uri) {
     $this->uri = $uri;
     return $this;
   }
 
   public function getURI() {
     return $this->uri;
   }
 
   public function setPHID($phid) {
     $this->phid = $phid;
     return $this;
   }
 
   public function getPHID() {
     return $this->phid;
   }
 
   public function setName($name) {
     $this->name = $name;
     return $this;
   }
 
   public function getName() {
     if ($this->name === null) {
       if ($this->getPolicyFiltered()) {
         return pht('Restricted %s', $this->getTypeName());
       } else {
         return pht('Unknown Object (%s)', $this->getTypeName());
       }
     }
     return $this->name;
   }
 
   public function setAvailability($availability) {
     $this->availability = $availability;
     return $this;
   }
 
   public function getAvailability() {
     return $this->availability;
   }
 
   public function isDisabled() {
     return ($this->getAvailability() == self::AVAILABILITY_DISABLED);
   }
 
   public function setStatus($status) {
     $this->status = $status;
     return $this;
   }
 
   public function getStatus() {
     return $this->status;
   }
 
   public function isClosed() {
     return ($this->status === self::STATUS_CLOSED);
   }
 
   public function setFullName($full_name) {
     $this->fullName = $full_name;
     return $this;
   }
 
   public function getFullName() {
     if ($this->fullName !== null) {
       return $this->fullName;
     }
     return $this->getName();
   }
 
   public function setCommandLineObjectName($command_line_object_name) {
     $this->commandLineObjectName = $command_line_object_name;
     return $this;
   }
 
   public function getCommandLineObjectName() {
     if ($this->commandLineObjectName !== null) {
       return $this->commandLineObjectName;
     }
 
     return $this->getObjectName();
   }
 
   public function setTitle($title) {
     $this->title = $title;
     return $this;
   }
 
   public function getTitle() {
     return $this->title;
   }
 
   public function setType($type) {
     $this->type = $type;
     return $this;
   }
 
   public function getType() {
     return $this->type;
   }
 
   public function setImageURI($uri) {
     $this->imageURI = $uri;
     return $this;
   }
 
   public function getImageURI() {
     return $this->imageURI;
   }
 
   public function setTimestamp($timestamp) {
     $this->timestamp = $timestamp;
     return $this;
   }
 
   public function getTimestamp() {
     return $this->timestamp;
   }
 
   public function getTypeName() {
     if ($this->getPHIDType()) {
       return $this->getPHIDType()->getTypeName();
     }
 
     return $this->getType();
   }
 
 
   /**
    * Set whether or not the underlying object is complete. See
    * @{method:isComplete} for an explanation of what it means to be complete.
    *
-   * @param bool True if the handle represents a complete object.
+   * @param bool $complete True if the handle represents a complete object.
    * @return this
    */
   public function setComplete($complete) {
     $this->complete = $complete;
     return $this;
   }
 
 
   /**
    * Determine if the handle represents an object which was completely loaded
    * (i.e., the underlying object exists) vs an object which could not be
    * completely loaded (e.g., the type or data for the PHID could not be
    * identified or located).
    *
    * Basically, @{class:PhabricatorHandleQuery} gives you back a handle for
    * any PHID you give it, but it gives you a complete handle only for valid
    * PHIDs.
    *
    * @return bool True if the handle represents a complete object.
    */
   public function isComplete() {
     return $this->complete;
   }
 
   public function renderLink($name = null) {
     return $this->renderLinkWithAttributes($name, array());
   }
 
   public function renderHovercardLink($name = null, $context_phid = null) {
     Javelin::initBehavior('phui-hovercards');
 
     $hovercard_spec = array(
       'objectPHID' => $this->getPHID(),
     );
 
     if ($context_phid) {
       $hovercard_spec['contextPHID'] = $context_phid;
     }
 
     $attributes = array(
       'sigil' => 'hovercard',
       'meta' => array(
         'hovercardSpec' => $hovercard_spec,
       ),
     );
 
     return $this->renderLinkWithAttributes($name, $attributes);
   }
 
   private function renderLinkWithAttributes($name, array $attributes) {
     if ($name === null) {
       $name = $this->getLinkName();
     }
     $classes = array();
     $classes[] = 'phui-handle';
     $title = $this->title;
 
     if ($this->status != self::STATUS_OPEN) {
       $classes[] = 'handle-status-'.$this->status;
     }
 
     $circle = null;
     if ($this->availability != self::AVAILABILITY_FULL) {
       $classes[] = 'handle-availability-'.$this->availability;
       $circle = array(
         phutil_tag(
           'span',
           array(
             'class' => 'perfect-circle',
           ),
           "\xE2\x80\xA2"),
         ' ',
       );
     }
 
     if ($this->getType() == PhabricatorPeopleUserPHIDType::TYPECONST) {
       $classes[] = 'phui-link-person';
     }
 
     $uri = $this->getURI();
 
     $icon = null;
     if ($this->getPolicyFiltered()) {
       $icon = id(new PHUIIconView())
         ->setIcon('fa-lock lightgreytext');
     }
 
     $attributes = $attributes + array(
       'href'  => $uri,
       'class' => implode(' ', $classes),
       'title' => $title,
     );
 
     return javelin_tag(
       $uri ? 'a' : 'span',
       $attributes,
       array($circle, $icon, $name));
   }
 
   public function renderTag() {
     return id(new PHUITagView())
       ->setType(PHUITagView::TYPE_SHADE)
       ->setColor($this->getTagColor())
       ->setIcon($this->getIcon())
       ->setHref($this->getURI())
       ->setName($this->getLinkName());
   }
 
   public function getLinkName() {
     switch ($this->getType()) {
       case PhabricatorPeopleUserPHIDType::TYPECONST:
         $name = $this->getName();
         break;
       default:
         $name = $this->getFullName();
         break;
     }
     return $name;
   }
 
   protected function getPHIDType() {
     $types = PhabricatorPHIDType::getAllTypes();
     return idx($types, $this->getType());
   }
 
   public function hasCapabilities() {
     if (!$this->isComplete()) {
       return false;
     }
 
     return ($this->getType() === PhabricatorPeopleUserPHIDType::TYPECONST);
   }
 
   public function attachCapability(
     PhabricatorPolicyInterface $object,
     $capability,
     $has_capability) {
 
     if (!$this->hasCapabilities()) {
       throw new Exception(
         pht(
           'Attempting to attach capability ("%s") for object ("%s") to '.
           'handle, but this handle (of type "%s") can not have '.
           'capabilities.',
           $capability,
           get_class($object),
           $this->getType()));
     }
 
     $object_key = $this->getObjectCapabilityKey($object);
     $this->capabilities[$object_key][$capability] = $has_capability;
 
     return $this;
   }
 
   public function hasViewCapability(PhabricatorPolicyInterface $object) {
     return $this->hasCapability($object, PhabricatorPolicyCapability::CAN_VIEW);
   }
 
   private function hasCapability(
     PhabricatorPolicyInterface $object,
     $capability) {
 
     $object_key = $this->getObjectCapabilityKey($object);
 
     if (!isset($this->capabilities[$object_key][$capability])) {
       throw new Exception(
         pht(
           'Attempting to test capability "%s" for handle of type "%s", but '.
           'this capability has not been attached.',
           $capability,
           $this->getType()));
     }
 
     return $this->capabilities[$object_key][$capability];
   }
 
   private function getObjectCapabilityKey(PhabricatorPolicyInterface $object) {
     $object_phid = $object->getPHID();
 
     if (!$object_phid) {
       throw new Exception(
         pht(
           'Object (of class "%s") has no PHID, so handles can not interact '.
           'with capabilities for it.',
           get_class($object)));
     }
 
     return $object_phid;
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
     );
   }
 
   public function getPolicy($capability) {
     return PhabricatorPolicies::POLICY_PUBLIC;
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     // NOTE: Handles are always visible, they just don't get populated with
     // data if the user can't see the underlying object.
     return true;
   }
 
   public function describeAutomaticCapability($capability) {
     return null;
   }
 
 }
diff --git a/src/applications/phid/query/PhabricatorObjectQuery.php b/src/applications/phid/query/PhabricatorObjectQuery.php
index 8364f0210d..f1dd1feb4d 100644
--- a/src/applications/phid/query/PhabricatorObjectQuery.php
+++ b/src/applications/phid/query/PhabricatorObjectQuery.php
@@ -1,227 +1,227 @@
 <?php
 
 final class PhabricatorObjectQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $phids = array();
   private $names = array();
   private $types;
 
   private $namedResults;
 
   public function withPHIDs(array $phids) {
     $this->phids = $phids;
     return $this;
   }
 
   public function withNames(array $names) {
     $this->names = $names;
     return $this;
   }
 
   public function withTypes(array $types) {
     $this->types = $types;
     return $this;
   }
 
   protected function loadPage() {
     if ($this->namedResults === null) {
       $this->namedResults = array();
     }
 
     $names = array_unique($this->names);
     $phids = $this->phids;
 
     // We allow objects to be named by their PHID in addition to their normal
     // name so that, e.g., CLI tools which accept object names can also accept
     // PHIDs and work as users expect.
     $actually_phids = array();
     if ($names) {
       foreach ($names as $key => $name) {
         if (!strncmp($name, 'PHID-', 5)) {
           $actually_phids[] = $name;
           $phids[] = $name;
           unset($names[$key]);
         }
       }
     }
 
     if ($names) {
       $types = PhabricatorPHIDType::getAllTypes();
       if ($this->types) {
         $types = array_select_keys($types, $this->types);
       }
       $name_results = $this->loadObjectsByName($types, $names);
     } else {
       $name_results = array();
     }
 
     if ($phids) {
       $phids = array_unique($phids);
 
       $phid_types = array();
       foreach ($phids as $phid) {
         $phid_type = phid_get_type($phid);
         $phid_types[$phid_type] = $phid_type;
       }
 
       $types = PhabricatorPHIDType::getTypes($phid_types);
       if ($this->types) {
         $types = array_select_keys($types, $this->types);
       }
 
       $phid_results = $this->loadObjectsByPHID($types, $phids);
     } else {
       $phid_results = array();
     }
 
     foreach ($actually_phids as $phid) {
       if (isset($phid_results[$phid])) {
         $name_results[$phid] = $phid_results[$phid];
       }
     }
 
     $this->namedResults += $name_results;
 
     return $phid_results + mpull($name_results, null, 'getPHID');
   }
 
   public function getNamedResults() {
     if ($this->namedResults === null) {
       throw new PhutilInvalidStateException('execute');
     }
     return $this->namedResults;
   }
 
   private function loadObjectsByName(array $types, array $names) {
     $groups = array();
     foreach ($names as $name) {
       foreach ($types as $type => $type_impl) {
         if (!$type_impl->canLoadNamedObject($name)) {
           continue;
         }
         $groups[$type][] = $name;
         break;
       }
     }
 
     $results = array();
     foreach ($groups as $type => $group) {
       $results += $types[$type]->loadNamedObjects($this, $group);
     }
 
     return $results;
   }
 
   private function loadObjectsByPHID(array $types, array $phids) {
     $results = array();
 
     $groups = array();
     foreach ($phids as $phid) {
       $type = phid_get_type($phid);
       $groups[$type][] = $phid;
     }
 
     $in_flight = $this->getPHIDsInFlight();
     foreach ($groups as $type => $group) {
       // We check the workspace for each group, because some groups may trigger
       // other groups to load (for example, transactions load their objects).
       $workspace = $this->getObjectsFromWorkspace($group);
 
       foreach ($group as $key => $phid) {
         if (isset($workspace[$phid])) {
           $results[$phid] = $workspace[$phid];
           unset($group[$key]);
         }
       }
 
       if (!$group) {
         continue;
       }
 
       // Don't try to load PHIDs which are already "in flight"; this prevents
       // us from recursing indefinitely if policy checks or edges form a loop.
       // We will decline to load the corresponding objects.
       foreach ($group as $key => $phid) {
         if (isset($in_flight[$phid])) {
           unset($group[$key]);
         }
       }
 
       if ($group && isset($types[$type])) {
         $this->putPHIDsInFlight($group);
         $objects = $types[$type]->loadObjects($this, $group);
 
         $map = mpull($objects, null, 'getPHID');
         $this->putObjectsInWorkspace($map);
         $results += $map;
       }
     }
 
     return $results;
   }
 
   protected function didFilterResults(array $filtered) {
     foreach ($this->namedResults as $name => $result) {
       if (isset($filtered[$result->getPHID()])) {
         unset($this->namedResults[$name]);
       }
     }
   }
 
   /**
    * This query disables policy filtering if the only required capability is
    * the view capability.
    *
    * The view capability is always checked in the subqueries, so we do not need
    * to re-filter results. For any other set of required capabilities, we do.
    */
   protected function shouldDisablePolicyFiltering() {
     $view_capability = PhabricatorPolicyCapability::CAN_VIEW;
     if ($this->getRequiredCapabilities() === array($view_capability)) {
       return true;
     }
     return false;
   }
 
   public function getQueryApplicationClass() {
     return null;
   }
 
 
   /**
    * Select invalid or restricted PHIDs from a list.
    *
    * PHIDs are invalid if their objects do not exist or can not be seen by the
    * viewer. This method is generally used to validate that PHIDs affected by
    * a transaction are valid.
    *
-   * @param PhabricatorUser Viewer.
-   * @param list<phid> List of ostensibly valid PHIDs.
+   * @param PhabricatorUser $viewer Viewer.
+   * @param list<phid> $phids List of ostensibly valid PHIDs.
    * @return list<phid> List of invalid or restricted PHIDs.
    */
   public static function loadInvalidPHIDsForViewer(
     PhabricatorUser $viewer,
     array $phids) {
 
     if (!$phids) {
       return array();
     }
 
     $objects = id(new PhabricatorObjectQuery())
       ->setViewer($viewer)
       ->withPHIDs($phids)
       ->execute();
     $objects = mpull($objects, null, 'getPHID');
 
     $invalid = array();
     foreach ($phids as $phid) {
       if (empty($objects[$phid])) {
         $invalid[] = $phid;
       }
     }
 
     return $invalid;
   }
 
 }
diff --git a/src/applications/phid/type/PhabricatorPHIDType.php b/src/applications/phid/type/PhabricatorPHIDType.php
index 6a283a7d08..fda09c00d8 100644
--- a/src/applications/phid/type/PhabricatorPHIDType.php
+++ b/src/applications/phid/type/PhabricatorPHIDType.php
@@ -1,231 +1,232 @@
 <?php
 
 abstract class PhabricatorPHIDType extends Phobject {
 
   final public function getTypeConstant() {
     $const = $this->getPhobjectClassConstant('TYPECONST');
 
     if (!is_string($const) || !preg_match('/^[A-Z]{4}$/', $const)) {
       throw new Exception(
         pht(
           '%s class "%s" has an invalid %s property. PHID '.
           'constants must be a four character uppercase string.',
           __CLASS__,
           get_class($this),
           'TYPECONST'));
     }
 
     return $const;
   }
 
   abstract public function getTypeName();
 
   public function getTypeIcon() {
     // Default to the application icon if the type doesn't specify one.
     $application_class = $this->getPHIDTypeApplicationClass();
     if ($application_class) {
       $application = newv($application_class, array());
       return $application->getIcon();
     }
 
     return null;
   }
 
   public function newObject() {
     return null;
   }
 
 
   /**
    * Get the class name for the application this type belongs to.
    *
    * @return string|null Class name of the corresponding application, or null
    *   if the type is not bound to an application.
    */
   abstract public function getPHIDTypeApplicationClass();
 
   /**
    * Build a @{class:PhabricatorPolicyAwareQuery} to load objects of this type
    * by PHID.
    *
    * If you can not build a single query which satisfies this requirement, you
    * can provide a dummy implementation for this method and overload
    * @{method:loadObjects} instead.
    *
-   * @param PhabricatorObjectQuery Query being executed.
-   * @param list<phid> PHIDs to load.
+   * @param PhabricatorObjectQuery $query Query being executed.
+   * @param list<phid> $phids PHIDs to load.
    * @return PhabricatorPolicyAwareQuery Query object which loads the
    *   specified PHIDs when executed.
    */
   abstract protected function buildQueryForObjects(
     PhabricatorObjectQuery $query,
     array $phids);
 
 
   /**
    * Load objects of this type, by PHID. For most PHID types, it is only
    * necessary to implement @{method:buildQueryForObjects} to get object
    * loading to work.
    *
-   * @param PhabricatorObjectQuery Query being executed.
-   * @param list<phid> PHIDs to load.
+   * @param PhabricatorObjectQuery $query Query being executed.
+   * @param list<phid> $phids PHIDs to load.
    * @return list<wild> Corresponding objects.
    */
   public function loadObjects(
     PhabricatorObjectQuery $query,
     array $phids) {
 
     $object_query = $this->buildQueryForObjects($query, $phids)
       ->setViewer($query->getViewer())
       ->setParentQuery($query);
 
     // If the user doesn't have permission to use the application at all,
     // just mark all the PHIDs as filtered. This primarily makes these
     // objects show up as "Restricted" instead of "Unknown" when loaded as
     // handles, which is technically true.
     if (!$object_query->canViewerUseQueryApplication()) {
       $object_query->addPolicyFilteredPHIDs(array_fuse($phids));
       return array();
     }
 
     return $object_query->execute();
   }
 
 
   /**
    * Populate provided handles with application-specific data, like titles and
    * URIs.
    *
    * NOTE: The `$handles` and `$objects` lists are guaranteed to be nonempty
    * and have the same keys: subclasses are expected to load information only
    * for handles with visible objects.
    *
    * Because of this guarantee, a safe implementation will typically look like*
    *
    *   foreach ($handles as $phid => $handle) {
    *     $object = $objects[$phid];
    *
    *     $handle->setStuff($object->getStuff());
    *     // ...
    *   }
    *
    * In general, an implementation should call `setName()` and `setURI()` on
    * each handle at a minimum. See @{class:PhabricatorObjectHandle} for other
    * handle properties.
    *
-   * @param PhabricatorHandleQuery          Issuing query object.
-   * @param list<PhabricatorObjectHandle>   Handles to populate with data.
-   * @param list<Object>                    Objects for these PHIDs loaded by
-   *                                        @{method:buildQueryForObjects()}.
+   * @param PhabricatorHandleQuery        $query    Issuing query object.
+   * @param list<PhabricatorObjectHandle> $handles  Handles to populate with
+   *   data.
+   * @param list<Object>                  $objects  Objects for these PHIDs
+   *   loaded by @{method:buildQueryForObjects()}.
    * @return void
    */
   abstract public function loadHandles(
     PhabricatorHandleQuery $query,
     array $handles,
     array $objects);
 
   public function canLoadNamedObject($name) {
     return false;
   }
 
   public function loadNamedObjects(
     PhabricatorObjectQuery $query,
     array $names) {
     throw new PhutilMethodNotImplementedException();
   }
 
 
   /**
    * Get all known PHID types.
    *
    * To get PHID types a given user has access to, see
    * @{method:getAllInstalledTypes}.
    *
    * @return dict<string, PhabricatorPHIDType> Map of type constants to types.
    */
   final public static function getAllTypes() {
     return self::newClassMapQuery()
       ->execute();
   }
 
   final public static function getTypes(array $types) {
     return id(new PhabricatorCachedClassMapQuery())
       ->setClassMapQuery(self::newClassMapQuery())
       ->setMapKeyMethod('getTypeConstant')
       ->loadClasses($types);
   }
 
   private static function newClassMapQuery() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getTypeConstant');
   }
 
 
   /**
    * Get all PHID types of applications installed for a given viewer.
    *
-   * @param PhabricatorUser Viewing user.
+   * @param PhabricatorUser $viewer Viewing user.
    * @return dict<string, PhabricatorPHIDType> Map of constants to installed
    *  types.
    */
   public static function getAllInstalledTypes(PhabricatorUser $viewer) {
     $all_types = self::getAllTypes();
 
     $installed_types = array();
 
     $app_classes = array();
     foreach ($all_types as $key => $type) {
       $app_class = $type->getPHIDTypeApplicationClass();
 
       if ($app_class === null) {
         // If the PHID type isn't bound to an application, include it as
         // installed.
         $installed_types[$key] = $type;
         continue;
       }
 
       // Otherwise, we need to check if this application is installed before
       // including the PHID type.
       $app_classes[$app_class][$key] = $type;
     }
 
     if ($app_classes) {
       $apps = id(new PhabricatorApplicationQuery())
         ->setViewer($viewer)
         ->withInstalled(true)
         ->withClasses(array_keys($app_classes))
         ->execute();
 
       foreach ($apps as $app_class => $app) {
         $installed_types += $app_classes[$app_class];
       }
     }
 
     return $installed_types;
   }
 
 
   /**
    * Get all PHID types of an application.
    *
-   * @param string Class name of an application
+   * @param string $application Class name of an application
    * @return dict<string, PhabricatorPHIDType> Map of constants of application
    */
   public static function getAllTypesForApplication(
     string $application) {
     $all_types = self::getAllTypes();
 
     $application_types = array();
 
     foreach ($all_types as $key => $type) {
       if ($type->getPHIDTypeApplicationClass() != $application) {
         continue;
       }
 
       $application_types[$key] = $type;
     }
 
     return $application_types;
   }
 }
diff --git a/src/applications/phid/utils.php b/src/applications/phid/utils.php
index 152a547284..eb14c57df9 100644
--- a/src/applications/phid/utils.php
+++ b/src/applications/phid/utils.php
@@ -1,38 +1,38 @@
 <?php
 
 /**
  * Look up the type of a PHID. Returns
  * PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN if it fails to look up the type
  *
- * @param   phid Anything.
+ * @param   phid   $phid Anything.
  * @return  string A value from PhabricatorPHIDConstants (ideally)
  */
 function phid_get_type($phid) {
   $matches = null;
   if (is_string($phid) && preg_match('/^PHID-([^-]{4})-/', $phid, $matches)) {
     return $matches[1];
   }
   return PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN;
 }
 
 /**
  * Group a list of phids by type.
  *
- * @param   phids array of phids
+ * @param   $phids Array of phids
  * @return  map of phid type => list of phids
  */
 function phid_group_by_type($phids) {
   $result = array();
   foreach ($phids as $phid) {
     $type = phid_get_type($phid);
     $result[$type][] = $phid;
   }
   return $result;
 }
 
 function phid_get_subtype($phid) {
   if (isset($phid[14]) && ($phid[14] == '-')) {
     return substr($phid, 10, 4);
   }
   return null;
 }
diff --git a/src/applications/phrequent/storage/PhrequentTimeBlock.php b/src/applications/phrequent/storage/PhrequentTimeBlock.php
index 3800651476..8649b4f67a 100644
--- a/src/applications/phrequent/storage/PhrequentTimeBlock.php
+++ b/src/applications/phrequent/storage/PhrequentTimeBlock.php
@@ -1,323 +1,324 @@
 <?php
 
 final class PhrequentTimeBlock extends Phobject {
 
   private $events;
 
   public function __construct(array $events) {
     assert_instances_of($events, 'PhrequentUserTime');
     $this->events = $events;
   }
 
   public function getTimeSpentOnObject($phid, $now) {
     $slices = idx($this->getObjectTimeRanges(), $phid);
 
     if (!$slices) {
       return null;
     }
 
     return $slices->getDuration($now);
   }
 
   public function getObjectTimeRanges() {
     $ranges = array();
 
     $range_start = time();
     foreach ($this->events as $event) {
       $range_start = min($range_start, $event->getDateStarted());
     }
 
     $object_ranges = array();
     $object_ongoing = array();
     foreach ($this->events as $event) {
 
       // First, convert each event's preempting stack into a linear timeline
       // of events.
 
       $timeline = array();
       $timeline[] = array(
         'event' => $event,
         'at' => (int)$event->getDateStarted(),
         'type' => 'start',
       );
       $timeline[] = array(
         'event' => $event,
         'at' => (int)nonempty($event->getDateEnded(), PHP_INT_MAX),
         'type' => 'end',
       );
 
       $base_phid = $event->getObjectPHID();
       if (!$event->getDateEnded()) {
         $object_ongoing[$base_phid] = true;
       }
 
       $preempts = $event->getPreemptingEvents();
       foreach ($preempts as $preempt) {
         $same_object = ($preempt->getObjectPHID() == $base_phid);
         $timeline[] = array(
           'event' => $preempt,
           'at' => (int)$preempt->getDateStarted(),
           'type' => $same_object ? 'start' : 'push',
         );
         $timeline[] = array(
           'event' => $preempt,
           'at' => (int)nonempty($preempt->getDateEnded(), PHP_INT_MAX),
           'type' => $same_object ? 'end' : 'pop',
         );
       }
 
       // Now, figure out how much time was actually spent working on the
       // object.
 
       usort($timeline, array(__CLASS__, 'sortTimeline'));
 
       $stack = array();
       $depth = null;
 
       // NOTE: "Strata" track the separate layers between each event tracking
       // the object we care about. Events might look like this:
       //
       //             |xxxxxxxxxxxxxxxxx|
       //         |yyyyyyy|
       //    |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
       //   9AM                                            5PM
       //
       // ...where we care about event "x". When "y" is popped, that shouldn't
       // pop the top stack -- we need to pop the stack a level down. Each
       // event tracking "x" creates a new stratum, and we keep track of where
       // timeline events are among the strata in order to keep stack depths
       // straight.
 
       $stratum = null;
       $strata = array();
 
       $ranges = array();
       foreach ($timeline as $timeline_event) {
         $id = $timeline_event['event']->getID();
         $type = $timeline_event['type'];
 
         switch ($type) {
           case 'start':
             $stack[] = $depth;
             $depth = 0;
             $stratum = count($stack);
             $strata[$id] = $stratum;
             $range_start = $timeline_event['at'];
             break;
           case 'end':
             if ($strata[$id] == $stratum) {
               if ($depth == 0) {
                 $ranges[] = array($range_start, $timeline_event['at']);
                 $depth = array_pop($stack);
               } else {
                 // Here, we've prematurely ended the current stratum. Merge all
                 // the higher strata into it. This looks like this:
                 //
                 //                 V
                 //                 V
                 //              |zzzzzzzz|
                 //           |xxxxx|
                 //        |yyyyyyyyyyyyy|
                 //   |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
 
                 $depth = array_pop($stack) + $depth;
               }
             } else {
               // Here, we've prematurely ended a deeper stratum. Merge higher
               // strata. This looks like this:
               //
               //                V
               //                V
               //              |aaaaaaa|
               //            |xxxxxxxxxxxxxxxxxxx|
               //          |zzzzzzzzzzzzz|
               //        |xxxxxxx|
               //     |yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy|
               //   |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
 
               $extra = $stack[$strata[$id]];
               unset($stack[$strata[$id] - 1]);
               $stack = array_values($stack);
               $stack[$strata[$id] - 1] += $extra;
             }
 
             // Regardless of how we got here, we need to merge down any higher
             // strata.
             $target = $strata[$id];
             foreach ($strata as $strata_id => $id_stratum) {
               if ($id_stratum >= $target) {
                 $strata[$strata_id]--;
               }
             }
             $stratum = count($stack);
 
             unset($strata[$id]);
             break;
           case 'push':
             $strata[$id] = $stratum;
             if ($depth == 0) {
               $ranges[] = array($range_start, $timeline_event['at']);
             }
             $depth++;
             break;
           case 'pop':
             if ($strata[$id] == $stratum) {
               $depth--;
               if ($depth == 0) {
                 $range_start = $timeline_event['at'];
               }
             } else {
               $stack[$strata[$id]]--;
             }
             unset($strata[$id]);
             break;
         }
       }
 
       // Filter out ranges with an indefinite start time. These occur when
       // popping the stack when there are multiple ongoing events.
       foreach ($ranges as $key => $range) {
         if ($range[0] == PHP_INT_MAX) {
           unset($ranges[$key]);
         }
       }
 
       $object_ranges[$base_phid][] = $ranges;
     }
 
     // Collapse all the ranges so we don't double-count time.
     foreach ($object_ranges as $phid => $ranges) {
       $object_ranges[$phid] = self::mergeTimeRanges(array_mergev($ranges));
     }
 
     foreach ($object_ranges as $phid => $ranges) {
       foreach ($ranges as $key => $range) {
         if ($range[1] == PHP_INT_MAX) {
           $ranges[$key][1] = null;
         }
       }
 
       $object_ranges[$phid] = new PhrequentTimeSlices(
         $phid,
         isset($object_ongoing[$phid]),
         $ranges);
     }
 
     // Reorder the ranges to be more stack-like, so the first item is the
     // top of the stack.
     $object_ranges = array_reverse($object_ranges, $preserve_keys = true);
 
     return $object_ranges;
   }
 
   /**
    * Returns the current list of work.
    */
   public function getCurrentWorkStack($now, $include_inactive = false) {
     $ranges = $this->getObjectTimeRanges();
 
     $results = array();
     $active = null;
     foreach ($ranges as $phid => $slices) {
       if (!$include_inactive) {
         if (!$slices->getIsOngoing()) {
           continue;
         }
       }
 
       $results[] = array(
         'phid' => $phid,
         'time' => $slices->getDuration($now),
         'ongoing' => $slices->getIsOngoing(),
       );
     }
 
     return $results;
   }
 
 
   /**
    * Merge a list of time ranges (pairs of `<start, end>` epochs) so that no
    * elements overlap. For example, the ranges:
    *
    *   array(
    *     array(50, 150),
    *     array(100, 175),
    *   );
    *
    * ...are merged to:
    *
    *   array(
    *     array(50, 175),
    *   );
    *
    * This is used to avoid double-counting time on objects which had timers
    * started multiple times.
    *
-   * @param list<pair<int, int>> List of possibly overlapping time ranges.
+   * @param list<pair<int, int>> $ranges List of possibly overlapping time
+   *   ranges.
    * @return list<pair<int, int>> Nonoverlapping time ranges.
    */
   public static function mergeTimeRanges(array $ranges) {
     $ranges = isort($ranges, 0);
 
     $result = array();
 
     $current = null;
     foreach ($ranges as $key => $range) {
       if ($current === null) {
         $current = $range;
         continue;
       }
 
       if ($range[0] <= $current[1]) {
         $current[1] = max($range[1], $current[1]);
         continue;
       }
 
       $result[] = $current;
       $current = $range;
     }
 
     $result[] = $current;
 
     return $result;
   }
 
 
   /**
    * Sort events in timeline order. Notably, for events which occur on the same
    * second, we want to process end events after start events.
    */
   public static function sortTimeline(array $u, array $v) {
     // If these events occur at different times, ordering is obvious.
     if ($u['at'] != $v['at']) {
       return ($u['at'] < $v['at']) ? -1 : 1;
     }
 
     $u_end = ($u['type'] == 'end' || $u['type'] == 'pop');
     $v_end = ($v['type'] == 'end' || $v['type'] == 'pop');
 
     $u_id = $u['event']->getID();
     $v_id = $v['event']->getID();
 
     if ($u_end == $v_end) {
       // These are both start events or both end events. Sort them by ID.
       if (!$u_end) {
         return ($u_id < $v_id) ? -1 : 1;
       } else {
         return ($u_id < $v_id) ? 1 : -1;
       }
     } else {
       // Sort them (start, end) if they're the same event, and (end, start)
       // otherwise.
       if ($u_id == $v_id) {
         return $v_end ? -1 : 1;
       } else {
         return $v_end ? 1 : -1;
       }
     }
 
     return 0;
   }
 
 }
diff --git a/src/applications/policy/filter/PhabricatorPolicyFilter.php b/src/applications/policy/filter/PhabricatorPolicyFilter.php
index e7e8c86ee7..2913381be9 100644
--- a/src/applications/policy/filter/PhabricatorPolicyFilter.php
+++ b/src/applications/policy/filter/PhabricatorPolicyFilter.php
@@ -1,1046 +1,1048 @@
 <?php
 
 final class PhabricatorPolicyFilter extends Phobject {
 
   private $viewer;
   private $objects;
   private $capabilities;
   private $raisePolicyExceptions;
   private $userProjects;
   private $customPolicies = array();
   private $objectPolicies = array();
   private $forcedPolicy;
 
   public static function mustRetainCapability(
     PhabricatorUser $user,
     PhabricatorPolicyInterface $object,
     $capability) {
 
     if (!self::hasCapability($user, $object, $capability)) {
       throw new Exception(
         pht(
           "You can not make that edit, because it would remove your ability ".
           "to '%s' the object.",
           $capability));
     }
   }
 
   public static function requireCapability(
     PhabricatorUser $user,
     PhabricatorPolicyInterface $object,
     $capability) {
     $filter = id(new PhabricatorPolicyFilter())
       ->setViewer($user)
       ->requireCapabilities(array($capability))
       ->raisePolicyExceptions(true)
       ->apply(array($object));
   }
 
   /**
    * Perform a capability check, acting as though an object had a specific
    * policy. This is primarily used to check if a policy is valid (for example,
    * to prevent users from editing away their ability to edit an object).
    *
    * Specifically, a check like this:
    *
    *   PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
    *     $viewer,
    *     $object,
    *     PhabricatorPolicyCapability::CAN_EDIT,
    *     $potential_new_policy);
    *
    * ...will throw a @{class:PhabricatorPolicyException} if the new policy would
    * remove the user's ability to edit the object.
    *
-   * @param PhabricatorUser   The viewer to perform a policy check for.
-   * @param PhabricatorPolicyInterface The object to perform a policy check on.
-   * @param string            Capability to test.
-   * @param string            Perform the test as though the object has this
-   *                          policy instead of the policy it actually has.
+   * @param PhabricatorUser   $viewer The viewer to perform a policy check for.
+   * @param PhabricatorPolicyInterface $object The object to perform a policy
+   *                          check on.
+   * @param string            $capability Capability to test.
+   * @param string            $forced_policy Perform the test as though the
+   *                          object has this policy instead of the policy it
+   *                          actually has.
    * @return void
    */
   public static function requireCapabilityWithForcedPolicy(
     PhabricatorUser $viewer,
     PhabricatorPolicyInterface $object,
     $capability,
     $forced_policy) {
 
     id(new PhabricatorPolicyFilter())
       ->setViewer($viewer)
       ->requireCapabilities(array($capability))
       ->raisePolicyExceptions(true)
       ->forcePolicy($forced_policy)
       ->apply(array($object));
   }
 
   public static function hasCapability(
     PhabricatorUser $user,
     PhabricatorPolicyInterface $object,
     $capability) {
 
     $filter = new PhabricatorPolicyFilter();
     $filter->setViewer($user);
     $filter->requireCapabilities(array($capability));
     $result = $filter->apply(array($object));
 
     return (count($result) == 1);
   }
 
   public static function canInteract(
     PhabricatorUser $user,
     PhabricatorPolicyInterface $object) {
 
     $capabilities = self::getRequiredInteractCapabilities($object);
 
     foreach ($capabilities as $capability) {
       if (!self::hasCapability($user, $object, $capability)) {
         return false;
       }
     }
 
     return true;
   }
 
   public static function requireCanInteract(
     PhabricatorUser $user,
     PhabricatorPolicyInterface $object) {
 
     $capabilities = self::getRequiredInteractCapabilities($object);
     foreach ($capabilities as $capability) {
       self::requireCapability($user, $object, $capability);
     }
   }
 
   private static function getRequiredInteractCapabilities(
     PhabricatorPolicyInterface $object) {
     $capabilities = $object->getCapabilities();
     $capabilities = array_fuse($capabilities);
 
     $can_interact = PhabricatorPolicyCapability::CAN_INTERACT;
     $can_view = PhabricatorPolicyCapability::CAN_VIEW;
 
     $require = array();
 
     // If the object doesn't support a separate "Interact" capability, we
     // only use the "View" capability: for most objects, you can interact
     // with them if you can see them.
     $require[] = $can_view;
 
     if (isset($capabilities[$can_interact])) {
       $require[] = $can_interact;
     }
 
     return $require;
   }
 
   public function setViewer(PhabricatorUser $user) {
     $this->viewer = $user;
     return $this;
   }
 
   public function requireCapabilities(array $capabilities) {
     $this->capabilities = $capabilities;
     return $this;
   }
 
   public function raisePolicyExceptions($raise) {
     $this->raisePolicyExceptions = $raise;
     return $this;
   }
 
   public function forcePolicy($forced_policy) {
     $this->forcedPolicy = $forced_policy;
     return $this;
   }
 
   public function apply(array $objects) {
     assert_instances_of($objects, 'PhabricatorPolicyInterface');
 
     $viewer       = $this->viewer;
     $capabilities = $this->capabilities;
 
     if (!$viewer || !$capabilities) {
       throw new PhutilInvalidStateException('setViewer', 'requireCapabilities');
     }
 
     // If the viewer is omnipotent, short circuit all the checks and just
     // return the input unmodified. This is an optimization; we know the
     // result already.
     if ($viewer->isOmnipotent()) {
       return $objects;
     }
 
     // Before doing any actual object checks, make sure the viewer can see
     // the applications that these objects belong to. This is normally enforced
     // in the Query layer before we reach object filtering, but execution
     // sometimes reaches policy filtering without running application checks.
     $objects = $this->applyApplicationChecks($objects);
 
     $filtered = array();
     $viewer_phid = $viewer->getPHID();
 
     if (empty($this->userProjects[$viewer_phid])) {
       $this->userProjects[$viewer_phid] = array();
     }
 
     $need_projects = array();
     $need_policies = array();
     $need_objpolicies = array();
     foreach ($objects as $key => $object) {
       $object_capabilities = $object->getCapabilities();
       foreach ($capabilities as $capability) {
         if (!in_array($capability, $object_capabilities)) {
           throw new Exception(
             pht(
               'Testing for capability "%s" on an object ("%s") which does '.
               'not support that capability.',
               $capability,
               get_class($object)));
         }
 
         $policy = $this->getObjectPolicy($object, $capability);
 
         if (PhabricatorPolicyQuery::isObjectPolicy($policy)) {
           $need_objpolicies[$policy][] = $object;
           continue;
         }
 
         $type = phid_get_type($policy);
         if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) {
           $need_projects[$policy] = $policy;
           continue;
         }
 
         if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) {
           $need_policies[$policy][] = $object;
           continue;
         }
       }
     }
 
     if ($need_objpolicies) {
       $this->loadObjectPolicies($need_objpolicies);
     }
 
     if ($need_policies) {
       $this->loadCustomPolicies($need_policies);
     }
 
     // If we need projects, check if any of the projects we need are also the
     // objects we're filtering. Because of how project rules work, this is a
     // common case.
     if ($need_projects) {
       foreach ($objects as $object) {
         if ($object instanceof PhabricatorProject) {
           $project_phid = $object->getPHID();
           if (isset($need_projects[$project_phid])) {
             $is_member = $object->isUserMember($viewer_phid);
             $this->userProjects[$viewer_phid][$project_phid] = $is_member;
             unset($need_projects[$project_phid]);
           }
         }
       }
     }
 
     if ($need_projects) {
       $need_projects = array_unique($need_projects);
 
       // NOTE: We're using the omnipotent user here to avoid a recursive
       // descent into madness. We don't actually need to know if the user can
       // see these projects or not, since: the check is "user is member of
       // project", not "user can see project"; and membership implies
       // visibility anyway. Without this, we may load other projects and
       // re-enter the policy filter and generally create a huge mess.
 
       $projects = id(new PhabricatorProjectQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withMemberPHIDs(array($viewer->getPHID()))
         ->withPHIDs($need_projects)
         ->execute();
 
       foreach ($projects as $project) {
         $this->userProjects[$viewer_phid][$project->getPHID()] = true;
       }
     }
 
     foreach ($objects as $key => $object) {
       foreach ($capabilities as $capability) {
         if (!$this->checkCapability($object, $capability)) {
           // If we're missing any capability, move on to the next object.
           continue 2;
         }
       }
 
       // If we make it here, we have all of the required capabilities.
       $filtered[$key] = $object;
     }
 
     // If we survived the primary checks, apply extended checks to objects
     // with extended policies.
     $results = array();
     $extended = array();
     foreach ($filtered as $key => $object) {
       if ($object instanceof PhabricatorExtendedPolicyInterface) {
         $extended[$key] = $object;
       } else {
         $results[$key] = $object;
       }
     }
 
     if ($extended) {
       $results += $this->applyExtendedPolicyChecks($extended);
       // Put results back in the original order.
       $results = array_select_keys($results, array_keys($filtered));
     }
 
     return $results;
   }
 
   private function applyExtendedPolicyChecks(array $extended_objects) {
     $viewer = $this->viewer;
     $filter_capabilities = $this->capabilities;
 
     // Iterate over the objects we need to filter and pull all the nonempty
     // policies into a flat, structured list.
     $all_structs = array();
     foreach ($extended_objects as $key => $extended_object) {
       foreach ($filter_capabilities as $extended_capability) {
         $extended_policies = $extended_object->getExtendedPolicy(
           $extended_capability,
           $viewer);
         if (!$extended_policies) {
           continue;
         }
 
         foreach ($extended_policies as $extended_policy) {
           list($object, $capabilities) = $extended_policy;
 
           // Build a description of the capabilities we need to check. This
           // will be something like `"view"`, or `"edit view"`, or possibly
           // a longer string with custom capabilities. Later, group the objects
           // up into groups which need the same capabilities tested.
           $capabilities = (array)$capabilities;
           $capabilities = array_fuse($capabilities);
           ksort($capabilities);
           $group = implode(' ', $capabilities);
 
           $struct = array(
             'key' => $key,
             'for' => $extended_capability,
             'object' => $object,
             'capabilities' => $capabilities,
             'group' => $group,
           );
 
           $all_structs[] = $struct;
         }
       }
     }
 
     // Extract any bare PHIDs from the structs; we need to load these objects.
     // These are objects which are required in order to perform an extended
     // policy check but which the original viewer did not have permission to
     // see (they presumably had other permissions which let them load the
     // object in the first place).
     $all_phids = array();
     foreach ($all_structs as $idx => $struct) {
       $object = $struct['object'];
       if (is_string($object)) {
         $all_phids[$object] = $object;
       }
     }
 
     // If we have some bare PHIDs, we need to load the corresponding objects.
     if ($all_phids) {
       // We can pull these with the omnipotent user because we're immediately
       // filtering them.
       $ref_objects = id(new PhabricatorObjectQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withPHIDs($all_phids)
         ->execute();
       $ref_objects = mpull($ref_objects, null, 'getPHID');
     } else {
       $ref_objects = array();
     }
 
     // Group the list of checks by the capabilities we need to check.
     $groups = igroup($all_structs, 'group');
     foreach ($groups as $structs) {
       $head = head($structs);
 
       // All of the items in each group are checking for the same capabilities.
       $capabilities = $head['capabilities'];
 
       $key_map = array();
       $objects_in = array();
       foreach ($structs as $struct) {
         $extended_key = $struct['key'];
         if (empty($extended_objects[$extended_key])) {
           // If this object has already been rejected by an earlier filtering
           // pass, we don't need to do any tests on it.
           continue;
         }
 
         $object = $struct['object'];
         if (is_string($object)) {
           // This is really a PHID, so look it up.
           $object_phid = $object;
           if (empty($ref_objects[$object_phid])) {
             // We weren't able to load the corresponding object, so just
             // reject this result outright.
 
             $reject = $extended_objects[$extended_key];
             unset($extended_objects[$extended_key]);
 
             // TODO: This could be friendlier.
             $this->rejectObject($reject, false, '<bad-ref>');
             continue;
           }
           $object = $ref_objects[$object_phid];
         }
 
         $phid = $object->getPHID();
 
         $key_map[$phid][] = $extended_key;
         $objects_in[$phid] = $object;
       }
 
       if ($objects_in) {
         $objects_out = $this->executeExtendedPolicyChecks(
           $viewer,
           $capabilities,
           $objects_in,
           $key_map);
         $objects_out = mpull($objects_out, null, 'getPHID');
       } else {
         $objects_out = array();
       }
 
       // If any objects were removed by filtering, we're going to reject all
       // of the original objects which needed them.
       foreach ($objects_in as $phid => $object_in) {
         if (isset($objects_out[$phid])) {
           // This object survived filtering, so we don't need to throw any
           // results away.
           continue;
         }
 
         foreach ($key_map[$phid] as $extended_key) {
           if (empty($extended_objects[$extended_key])) {
             // We've already rejected this object, so we don't need to reject
             // it again.
             continue;
           }
 
           $reject = $extended_objects[$extended_key];
           unset($extended_objects[$extended_key]);
 
           // It's possible that we're rejecting this object for multiple
           // capability/policy failures, but just pick the first one to show
           // to the user.
           $first_capability = head($capabilities);
           $first_policy = $object_in->getPolicy($first_capability);
 
           $this->rejectObject($reject, $first_policy, $first_capability);
         }
       }
     }
 
     return $extended_objects;
   }
 
   private function executeExtendedPolicyChecks(
     PhabricatorUser $viewer,
     array $capabilities,
     array $objects,
     array $key_map) {
 
     // Do crude cycle detection by seeing if we have a huge stack depth.
     // Although more sophisticated cycle detection is possible in theory,
     // it is difficult with hierarchical objects like subprojects. Many other
     // checks make it difficult to create cycles normally, so just do a
     // simple check here to limit damage.
 
     static $depth = 0;
 
     $depth++;
 
     if ($depth > 32) {
       foreach ($objects as $key => $object) {
         $this->rejectObject($objects[$key], false, '<cycle>');
         unset($objects[$key]);
         continue;
       }
     }
 
     if (!$objects) {
       return array();
     }
 
     $caught = null;
     try {
       $result = id(new PhabricatorPolicyFilter())
         ->setViewer($viewer)
         ->requireCapabilities($capabilities)
         ->apply($objects);
     } catch (Exception $ex) {
       $caught = $ex;
     }
 
     $depth--;
 
     if ($caught) {
       throw $caught;
     }
 
     return $result;
   }
 
   private function checkCapability(
     PhabricatorPolicyInterface $object,
     $capability) {
 
     $policy = $this->getObjectPolicy($object, $capability);
 
     if (!$policy) {
       // TODO: Formalize this somehow?
       $policy = PhabricatorPolicies::POLICY_USER;
     }
 
     if ($policy == PhabricatorPolicies::POLICY_PUBLIC) {
       // If the object is set to "public" but that policy is disabled for this
       // install, restrict the policy to "user".
       if (!PhabricatorEnv::getEnvConfig('policy.allow-public')) {
         $policy = PhabricatorPolicies::POLICY_USER;
       }
 
       // If the object is set to "public" but the capability is not a public
       // capability, restrict the policy to "user".
       $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability);
       if (!$capobj || !$capobj->shouldAllowPublicPolicySetting()) {
         $policy = PhabricatorPolicies::POLICY_USER;
       }
     }
 
     $viewer = $this->viewer;
 
     if ($viewer->isOmnipotent()) {
       return true;
     }
 
     if ($object instanceof PhabricatorSpacesInterface) {
       $space_phid = $object->getSpacePHID();
       if (!$this->canViewerSeeObjectsInSpace($viewer, $space_phid)) {
         $this->rejectObjectFromSpace($object, $space_phid);
         return false;
       }
     }
 
     if ($object->hasAutomaticCapability($capability, $viewer)) {
       return true;
     }
 
     switch ($policy) {
       case PhabricatorPolicies::POLICY_PUBLIC:
         return true;
       case PhabricatorPolicies::POLICY_USER:
         if ($viewer->getPHID()) {
           return true;
         } else {
           $this->rejectObject($object, $policy, $capability);
         }
         break;
       case PhabricatorPolicies::POLICY_ADMIN:
         if ($viewer->getIsAdmin()) {
           return true;
         } else {
           $this->rejectObject($object, $policy, $capability);
         }
         break;
       case PhabricatorPolicies::POLICY_NOONE:
         $this->rejectObject($object, $policy, $capability);
         break;
       default:
         if (PhabricatorPolicyQuery::isObjectPolicy($policy)) {
           if ($this->checkObjectPolicy($policy, $object)) {
             return true;
           } else {
             $this->rejectObject($object, $policy, $capability);
             break;
           }
         }
 
         $type = phid_get_type($policy);
         if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) {
           if (!empty($this->userProjects[$viewer->getPHID()][$policy])) {
             return true;
           } else {
             $this->rejectObject($object, $policy, $capability);
           }
         } else if ($type == PhabricatorPeopleUserPHIDType::TYPECONST) {
           if ($viewer->getPHID() == $policy) {
             return true;
           } else {
             $this->rejectObject($object, $policy, $capability);
           }
         } else if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) {
           if ($this->checkCustomPolicy($policy, $object)) {
             return true;
           } else {
             $this->rejectObject($object, $policy, $capability);
           }
         } else {
           // Reject objects with unknown policies.
           $this->rejectObject($object, false, $capability);
         }
     }
 
     return false;
   }
 
   public function rejectObject(
     PhabricatorPolicyInterface $object,
     $policy,
     $capability) {
     $viewer = $this->viewer;
 
     if (!$this->raisePolicyExceptions) {
       return;
     }
 
     if ($viewer->isOmnipotent()) {
       // Never raise policy exceptions for the omnipotent viewer. Although we
       // will never normally issue a policy rejection for the omnipotent
       // viewer, we can end up here when queries blanket reject objects that
       // have failed to load, without distinguishing between nonexistent and
       // nonvisible objects.
       return;
     }
 
     $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability);
     $rejection = null;
     if ($capobj) {
       $rejection = $capobj->describeCapabilityRejection();
       $capability_name = $capobj->getCapabilityName();
     } else {
       $capability_name = $capability;
     }
 
     if (!$rejection) {
       // We couldn't find the capability object, or it doesn't provide a
       // tailored rejection string.
       $rejection = pht(
         'You do not have the required capability ("%s") to do whatever you '.
         'are trying to do.',
         $capability);
     }
 
     // See T13411. If you receive a policy exception because you can't view
     // an object, we also want to avoid disclosing too many details about the
     // actual policy (for example, the names of projects in the policy).
 
     // If you failed a "CAN_VIEW" check, or failed some other check and don't
     // have "CAN_VIEW" on the object, we give you an "opaque" explanation.
     // Otherwise, we give you a more detailed explanation.
 
     $view_capability = PhabricatorPolicyCapability::CAN_VIEW;
     if ($capability === $view_capability) {
       $show_details = false;
     } else {
       $show_details = self::hasCapability(
         $viewer,
         $object,
         $view_capability);
     }
 
     // TODO: This is a bit clumsy. We're producing HTML and text versions of
     // this message, but can't render the full policy rules in text today.
     // Users almost never get a text-only version of this exception anyway.
 
     $head = null;
     $more = null;
 
     if ($show_details) {
       $head = PhabricatorPolicy::getPolicyExplanation($viewer, $policy);
 
       $policy_type = PhabricatorPolicyPHIDTypePolicy::TYPECONST;
       $is_custom = (phid_get_type($policy) === $policy_type);
       if ($is_custom) {
         $policy_map = PhabricatorPolicyQuery::loadPolicies(
           $viewer,
           $object);
         if (isset($policy_map[$capability])) {
           require_celerity_resource('phui-policy-section-view-css');
 
           $more = id(new PhabricatorPolicyRulesView())
             ->setViewer($viewer)
             ->setPolicy($policy_map[$capability]);
 
           $more = phutil_tag(
             'div',
             array(
               'class' => 'phui-policy-section-view-rules',
             ),
             $more);
         }
       }
     } else {
       $head = PhabricatorPolicy::getOpaquePolicyExplanation($viewer, $policy);
     }
 
     $head = (array)$head;
 
     $exceptions = PhabricatorPolicy::getSpecialRules(
       $object,
       $this->viewer,
       $capability,
       true);
 
     $text_details = array_filter(array_merge($head, $exceptions));
     $text_details = implode(' ', $text_details);
 
     $html_details = array($head, $more, phutil_implode_html(' ', $exceptions));
 
     $access_denied = $this->renderAccessDenied($object);
 
     $full_message = pht(
       '[%s] (%s) %s // %s',
       $access_denied,
       $capability_name,
       $rejection,
       $text_details);
 
     $exception = id(new PhabricatorPolicyException($full_message))
       ->setTitle($access_denied)
       ->setObjectPHID($object->getPHID())
       ->setRejection($rejection)
       ->setCapability($capability)
       ->setCapabilityName($capability_name)
       ->setMoreInfo($html_details);
 
     throw $exception;
   }
 
   private function loadObjectPolicies(array $map) {
     $viewer = $this->viewer;
     $viewer_phid = $viewer->getPHID();
 
     $rules = PhabricatorPolicyQuery::getObjectPolicyRules(null);
 
     // Make sure we have clean, empty policy rule objects.
     foreach ($rules as $key => $rule) {
       $rules[$key] = clone $rule;
     }
 
     $results = array();
     foreach ($map as $key => $object_list) {
       $rule = idx($rules, $key);
       if (!$rule) {
         continue;
       }
 
       foreach ($object_list as $object_key => $object) {
         if (!$rule->canApplyToObject($object)) {
           unset($object_list[$object_key]);
         }
       }
 
       $rule->willApplyRules($viewer, array(), $object_list);
       $results[$key] = $rule;
     }
 
     $this->objectPolicies[$viewer_phid] = $results;
   }
 
   private function loadCustomPolicies(array $map) {
     $viewer = $this->viewer;
     $viewer_phid = $viewer->getPHID();
 
     $custom_policies = id(new PhabricatorPolicyQuery())
       ->setViewer($viewer)
       ->withPHIDs(array_keys($map))
       ->execute();
     $custom_policies = mpull($custom_policies, null, 'getPHID');
 
     $classes = array();
     $values = array();
     $objects = array();
     foreach ($custom_policies as $policy_phid => $policy) {
       foreach ($policy->getCustomRuleClasses() as $class) {
         $classes[$class] = $class;
         $values[$class][] = $policy->getCustomRuleValues($class);
 
         foreach (idx($map, $policy_phid, array()) as $object) {
           $objects[$class][] = $object;
         }
       }
     }
 
     foreach ($classes as $class => $ignored) {
       $rule_object = newv($class, array());
 
       // Filter out any objects which the rule can't apply to.
       $target_objects = idx($objects, $class, array());
       foreach ($target_objects as $key => $target_object) {
         if (!$rule_object->canApplyToObject($target_object)) {
           unset($target_objects[$key]);
         }
       }
 
       $rule_object->willApplyRules(
         $viewer,
         array_mergev($values[$class]),
         $target_objects);
 
       $classes[$class] = $rule_object;
     }
 
     foreach ($custom_policies as $policy) {
       $policy->attachRuleObjects($classes);
     }
 
     if (empty($this->customPolicies[$viewer_phid])) {
       $this->customPolicies[$viewer_phid] = array();
     }
 
     $this->customPolicies[$viewer->getPHID()] += $custom_policies;
   }
 
   private function checkObjectPolicy(
     $policy_phid,
     PhabricatorPolicyInterface $object) {
     $viewer = $this->viewer;
     $viewer_phid = $viewer->getPHID();
 
     $rule = idx($this->objectPolicies[$viewer_phid], $policy_phid);
     if (!$rule) {
       return false;
     }
 
     if (!$rule->canApplyToObject($object)) {
       return false;
     }
 
     return $rule->applyRule($viewer, null, $object);
   }
 
   private function checkCustomPolicy(
     $policy_phid,
     PhabricatorPolicyInterface $object) {
 
     $viewer = $this->viewer;
     $viewer_phid = $viewer->getPHID();
 
     $policy = idx($this->customPolicies[$viewer_phid], $policy_phid);
     if (!$policy) {
       // Reject, this policy is bogus.
       return false;
     }
 
     $objects = $policy->getRuleObjects();
     $action = null;
     foreach ($policy->getRules() as $rule) {
       if (!is_array($rule)) {
         // Reject, this policy rule is invalid.
         return false;
       }
 
       $rule_object = idx($objects, idx($rule, 'rule'));
       if (!$rule_object) {
         // Reject, this policy has a bogus rule.
         return false;
       }
 
       if (!$rule_object->canApplyToObject($object)) {
         // Reject, this policy rule can't be applied to the given object.
         return false;
       }
 
       // If the user matches this rule, use this action.
       if ($rule_object->applyRule($viewer, idx($rule, 'value'), $object)) {
         $action = idx($rule, 'action');
         break;
       }
     }
 
     if ($action === null) {
       $action = $policy->getDefaultAction();
     }
 
     if ($action === PhabricatorPolicy::ACTION_ALLOW) {
       return true;
     }
 
     return false;
   }
 
   private function getObjectPolicy(
     PhabricatorPolicyInterface $object,
     $capability) {
 
     if ($this->forcedPolicy) {
       return $this->forcedPolicy;
     } else {
       return $object->getPolicy($capability);
     }
   }
 
   private function renderAccessDenied(PhabricatorPolicyInterface $object) {
     // NOTE: Not every type of policy object has a real PHID; just load an
     // empty handle if a real PHID isn't available.
     $phid = nonempty($object->getPHID(), PhabricatorPHIDConstants::PHID_VOID);
 
     $handle = id(new PhabricatorHandleQuery())
       ->setViewer($this->viewer)
       ->withPHIDs(array($phid))
       ->executeOne();
 
     $object_name = $handle->getObjectName();
 
     $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
     if ($is_serious) {
       $access_denied = pht(
         'Access Denied: %s',
         $object_name);
     } else {
       $access_denied = pht(
         'You Shall Not Pass: %s',
         $object_name);
     }
 
     return $access_denied;
   }
 
 
   private function canViewerSeeObjectsInSpace(
     PhabricatorUser $viewer,
     $space_phid) {
 
     $spaces = PhabricatorSpacesNamespaceQuery::getAllSpaces();
 
     // If there are no spaces, everything exists in an implicit default space
     // with no policy controls. This is the default state.
     if (!$spaces) {
       if ($space_phid !== null) {
         return false;
       } else {
         return true;
       }
     }
 
     if ($space_phid === null) {
       $space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
     } else {
       $space = idx($spaces, $space_phid);
     }
 
     if (!$space) {
       return false;
     }
 
     // This may be more involved later, but for now being able to see the
     // space is equivalent to being able to see everything in it.
     return self::hasCapability(
       $viewer,
       $space,
       PhabricatorPolicyCapability::CAN_VIEW);
   }
 
   private function rejectObjectFromSpace(
     PhabricatorPolicyInterface $object,
     $space_phid) {
 
     if (!$this->raisePolicyExceptions) {
       return;
     }
 
     if ($this->viewer->isOmnipotent()) {
       return;
     }
 
     $access_denied = $this->renderAccessDenied($object);
 
     $rejection = pht(
       'This object is in a space you do not have permission to access.');
     $full_message = pht('[%s] %s', $access_denied, $rejection);
 
     $exception = id(new PhabricatorPolicyException($full_message))
       ->setTitle($access_denied)
       ->setObjectPHID($object->getPHID())
       ->setRejection($rejection)
       ->setCapability(PhabricatorPolicyCapability::CAN_VIEW);
 
     throw $exception;
   }
 
   private function applyApplicationChecks(array $objects) {
     $viewer = $this->viewer;
 
     foreach ($objects as $key => $object) {
       // Don't filter handles: users are allowed to see handles from an
       // application they can't see even if they can not see objects from
       // that application. Note that the application policies still apply to
       // the underlying object, so these will be "Restricted Object" handles.
 
       // If we don't let these through, PhabricatorHandleQuery will completely
       // fail to load results for PHIDs that are part of applications which
       // the viewer can not see, but a fundamental property of handles is that
       // we always load something and they can safely be assumed to load.
       if ($object instanceof PhabricatorObjectHandle) {
         continue;
       }
 
       $phid = $object->getPHID();
       if (!$phid) {
         continue;
       }
 
       $application_class = $this->getApplicationForPHID($phid);
       if ($application_class === null) {
         continue;
       }
 
       $can_see = PhabricatorApplication::isClassInstalledForViewer(
         $application_class,
         $viewer);
       if ($can_see) {
         continue;
       }
 
       unset($objects[$key]);
 
       $application = newv($application_class, array());
       $this->rejectObject(
         $application,
         $application->getPolicy(PhabricatorPolicyCapability::CAN_VIEW),
         PhabricatorPolicyCapability::CAN_VIEW);
     }
 
     return $objects;
   }
 
   private function getApplicationForPHID($phid) {
     static $class_map = array();
 
     $phid_type = phid_get_type($phid);
     if (!isset($class_map[$phid_type])) {
       $type_objects = PhabricatorPHIDType::getTypes(array($phid_type));
       $type_object = idx($type_objects, $phid_type);
       if (!$type_object) {
         $class = false;
       } else {
         $class = $type_object->getPHIDTypeApplicationClass();
       }
 
       $class_map[$phid_type] = $class;
     }
 
     $class = $class_map[$phid_type];
     if ($class === false) {
       return null;
     }
 
     return $class;
   }
 
 }
diff --git a/src/applications/policy/interface/PhabricatorExtendedPolicyInterface.php b/src/applications/policy/interface/PhabricatorExtendedPolicyInterface.php
index deeaecdbef..1a56dacfba 100644
--- a/src/applications/policy/interface/PhabricatorExtendedPolicyInterface.php
+++ b/src/applications/policy/interface/PhabricatorExtendedPolicyInterface.php
@@ -1,88 +1,88 @@
 <?php
 
 /**
  * Allows an object to define a more complex policy than it can with
  * @{interface:PhabricatorPolicyInterface} alone.
  *
  * Some objects have complex policies which depend on the policies of other
  * objects. For example, users can generally only see a Revision in
  * Differential if they can also see the Repository it belongs to.
  *
  * These policies are normally enforced implicitly in the Query layer, by
  * discarding objects which have related objects that can not be loaded. In
  * most cases this has the same effect as really applying these policy checks
  * would.
  *
  * However, in some cases an object's policies are later checked by a different
  * viewer. For example, before we execute Herald rules, we check that the rule
  * owners can see the object we are about to evaluate.
  *
  * In these cases, we need to account for these complex policies. We could do
  * this by reloading the object over and over again for each viewer, but this
  * implies a large performance cost. Instead, extended policies make it
  * efficient to check policies against an object for multiple viewers.
  */
 interface PhabricatorExtendedPolicyInterface {
 
   /**
    * Get the extended policy for an object.
    *
    * Return a list of additional policy checks that the viewer must satisfy
    * in order to have the specified capability. This allows you to encode rules
    * like "to see a revision, the viewer must also be able to see the repository
    * it belongs to".
    *
    * For example, to specify that the viewer must be able to see some other
    * object in order to see this one, you could return:
    *
    *   return array(
    *     array($other_object, PhabricatorPolicyCapability::CAN_VIEW),
    *     // ...
    *   );
    *
    * If you don't have the actual object you want to check, you can return its
    * PHID instead:
    *
    *   return array(
    *     array($other_phid, PhabricatorPolicyCapability::CAN_VIEW),
    *     // ...
    *   );
    *
    * You can return a list of capabilities instead of a single capability if
    * you want to require multiple capabilities on a single object:
    *
    *   return array(
    *     array(
    *       $other_object,
    *       array(
    *         PhabricatorPolicyCapability::CAN_VIEW,
    *         PhabricatorPolicyCapability::CAN_EDIT,
    *       ),
    *     ),
    *     // ...
    *   );
    *
-   * @param const Capability being tested.
-   * @param PhabricatorUser Viewer whose capabilities are being tested.
+   * @param const $capability Capability being tested.
+   * @param PhabricatorUser $viewer Viewer whose capabilities are being tested.
    * @return list<pair<wild, wild>> List of extended policies.
    */
   public function getExtendedPolicy($capability, PhabricatorUser $viewer);
 
 }
 
 // TEMPLATE IMPLEMENTATION /////////////////////////////////////////////////////
 
 /* -(  PhabricatorExtendedPolicyInterface  )--------------------------------- */
 /*
 
   public function getExtendedPolicy($capability, PhabricatorUser $viewer) {
     $extended = array();
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         // ...
         break;
     }
     return $extended;
   }
 
 */
diff --git a/src/applications/policy/rule/PhabricatorPolicyRule.php b/src/applications/policy/rule/PhabricatorPolicyRule.php
index 2f9fac9cfb..fe3edce0aa 100644
--- a/src/applications/policy/rule/PhabricatorPolicyRule.php
+++ b/src/applications/policy/rule/PhabricatorPolicyRule.php
@@ -1,221 +1,221 @@
 <?php
 
 /**
  * @task objectpolicy Implementing Object Policies
  */
 abstract class PhabricatorPolicyRule extends Phobject {
 
   const CONTROL_TYPE_TEXT       = 'text';
   const CONTROL_TYPE_SELECT     = 'select';
   const CONTROL_TYPE_TOKENIZER  = 'tokenizer';
   const CONTROL_TYPE_NONE       = 'none';
 
   abstract public function getRuleDescription();
 
   public function willApplyRules(
     PhabricatorUser $viewer,
     array $values,
     array $objects) {
     return;
   }
 
   abstract public function applyRule(
     PhabricatorUser $viewer,
     $value,
     PhabricatorPolicyInterface $object);
 
   public function getValueControlType() {
     return self::CONTROL_TYPE_TEXT;
   }
 
   public function getValueControlTemplate() {
     return null;
   }
 
   /**
    * Return `true` if this rule can be applied to the given object.
    *
    * Some policy rules may only operation on certain kinds of objects. For
    * example, a "task author" rule can only operate on tasks.
    */
   public function canApplyToObject(PhabricatorPolicyInterface $object) {
     return true;
   }
 
   protected function getDatasourceTemplate(
     PhabricatorTypeaheadDatasource $datasource) {
 
     return array(
       'markup' => new AphrontTokenizerTemplateView(),
       'uri' => $datasource->getDatasourceURI(),
       'placeholder' => $datasource->getPlaceholderText(),
       'browseURI' => $datasource->getBrowseURI(),
     );
   }
 
   public function getRuleOrder() {
     return 500;
   }
 
   public function getValueForStorage($value) {
     return $value;
   }
 
   public function getValueForDisplay(PhabricatorUser $viewer, $value) {
     return $value;
   }
 
   public function getRequiredHandlePHIDsForSummary($value) {
     $phids = array();
 
     switch ($this->getValueControlType()) {
       case self::CONTROL_TYPE_TOKENIZER:
         $phids = $value;
         break;
       case self::CONTROL_TYPE_TEXT:
       case self::CONTROL_TYPE_SELECT:
       case self::CONTROL_TYPE_NONE:
       default:
         if (phid_get_type($value) !=
             PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
           $phids = array($value);
         } else {
           $phids = array();
         }
         break;
     }
 
     return $phids;
   }
 
   /**
    * Return `true` if the given value creates a rule with a meaningful effect.
    * An example of a rule with no meaningful effect is a "users" rule with no
    * users specified.
    *
    * @return bool True if the value creates a meaningful rule.
    */
   public function ruleHasEffect($value) {
     return true;
   }
 
 
 /* -(  Transaction Hints  )-------------------------------------------------- */
 
 
   /**
    * Tell policy rules about upcoming transaction effects.
    *
    * Before transaction effects are applied, we try to stop users from making
    * edits which will lock them out of objects. We can't do this perfectly,
    * since they can set a policy to "the moon is full" moments before it wanes,
    * but we try to prevent as many mistakes as possible.
    *
    * Some policy rules depend on complex checks against object state which
    * we can't set up ahead of time. For example, subscriptions require database
    * writes.
    *
    * In cases like this, instead of doing writes, you can pass a hint about an
    * object to a policy rule. The rule can then look for hints and use them in
    * rendering a verdict about whether the user will be able to see the object
    * or not after applying the policy change.
    *
-   * @param PhabricatorPolicyInterface Object to pass a hint about.
-   * @param PhabricatorPolicyRule Rule to pass hint to.
-   * @param wild Hint.
+   * @param PhabricatorPolicyInterface $object Object to pass a hint about.
+   * @param PhabricatorPolicyRule $rule Rule to pass hint to.
+   * @param wild $hint Hint.
    * @return void
    */
   public static function passTransactionHintToRule(
     PhabricatorPolicyInterface $object,
     PhabricatorPolicyRule $rule,
     $hint) {
 
     $cache = PhabricatorCaches::getRequestCache();
     $cache->setKey(self::getObjectPolicyCacheKey($object, $rule), $hint);
   }
 
   final protected function getTransactionHint(
     PhabricatorPolicyInterface $object) {
 
     $cache = PhabricatorCaches::getRequestCache();
     return $cache->getKey(self::getObjectPolicyCacheKey($object, $this));
   }
 
   private static function getObjectPolicyCacheKey(
     PhabricatorPolicyInterface $object,
     PhabricatorPolicyRule $rule) {
 
     // NOTE: This is quite a bit of a hack, but we don't currently have a
     // better way to carry hints from the TransactionEditor into PolicyRules
     // about pending policy changes.
 
     // Put some magic secret unique value on each object so we can pass
     // information about it by proxy. This allows us to test if pending
     // edits to an object will cause policy violations or not, before allowing
     // those edits to go through.
 
     // Some better approaches might be:
     //   - Use traits to give `PhabricatorPolicyInterface` objects real
     //     storage (requires PHP 5.4.0).
     //   - Wrap policy objects in a container with extra storage which the
     //     policy filter knows how to unbox (lots of work).
 
     // When this eventually gets cleaned up, the corresponding hack in
     // LiskDAO->__set() should also be cleaned up.
     static $id = 0;
     if (!isset($object->_hashKey)) {
       @$object->_hashKey = 'object.id('.(++$id).')';
     }
 
     return $object->_hashKey;
   }
 
 
 /* -(  Implementing Object Policies  )--------------------------------------- */
 
 
   /**
    * Return a unique string like "maniphest.author" to expose this rule as an
    * object policy.
    *
    * Object policy rules, like "Task Author", are more advanced than basic
    * policy rules (like "All Users") but not as powerful as custom rules.
    *
    * @return string Unique identifier for this rule.
    * @task objectpolicy
    */
   public function getObjectPolicyKey() {
     return null;
   }
 
   final public function getObjectPolicyFullKey() {
     $key = $this->getObjectPolicyKey();
 
     if (!$key) {
       throw new Exception(
         pht(
           'This policy rule (of class "%s") does not have an associated '.
           'object policy key.',
           get_class($this)));
     }
 
     return PhabricatorPolicyQuery::OBJECT_POLICY_PREFIX.$key;
   }
 
   public function getObjectPolicyName() {
     throw new PhutilMethodNotImplementedException();
   }
 
   public function getObjectPolicyShortName() {
     return $this->getObjectPolicyName();
   }
 
   public function getObjectPolicyIcon() {
     return 'fa-cube';
   }
 
   public function getPolicyExplanation() {
     throw new PhutilMethodNotImplementedException();
   }
 
 }
diff --git a/src/applications/policy/storage/PhabricatorPolicy.php b/src/applications/policy/storage/PhabricatorPolicy.php
index 5e5ec14107..56f15e37b8 100644
--- a/src/applications/policy/storage/PhabricatorPolicy.php
+++ b/src/applications/policy/storage/PhabricatorPolicy.php
@@ -1,525 +1,525 @@
 <?php
 
 final class PhabricatorPolicy
   extends PhabricatorPolicyDAO
   implements
     PhabricatorPolicyInterface,
     PhabricatorDestructibleInterface {
 
   const ACTION_ALLOW = 'allow';
   const ACTION_DENY = 'deny';
 
   private $name;
   private $shortName;
   private $type;
   private $href;
   private $workflow;
   private $icon;
 
   protected $rules = array();
   protected $defaultAction = self::ACTION_DENY;
 
   private $ruleObjects = self::ATTACHABLE;
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_SERIALIZATION => array(
         'rules' => self::SERIALIZATION_JSON,
       ),
       self::CONFIG_COLUMN_SCHEMA => array(
         'defaultAction' => 'text32',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_phid' => null,
         'phid' => array(
           'columns' => array('phid'),
           'unique' => true,
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function generatePHID() {
     return PhabricatorPHID::generateNewPHID(
       PhabricatorPolicyPHIDTypePolicy::TYPECONST);
   }
 
   public static function newFromPolicyAndHandle(
     $policy_identifier,
     PhabricatorObjectHandle $handle = null) {
 
     $is_global = PhabricatorPolicyQuery::isGlobalPolicy($policy_identifier);
     if ($is_global) {
       return PhabricatorPolicyQuery::getGlobalPolicy($policy_identifier);
     }
 
     $policy = PhabricatorPolicyQuery::getObjectPolicy($policy_identifier);
     if ($policy) {
       return $policy;
     }
 
     if (!$handle) {
       throw new Exception(
         pht(
           "Policy identifier is an object PHID ('%s'), but no object handle ".
           "was provided. A handle must be provided for object policies.",
           $policy_identifier));
     }
 
     $handle_phid = $handle->getPHID();
     if ($policy_identifier != $handle_phid) {
       throw new Exception(
         pht(
           "Policy identifier is an object PHID ('%s'), but the provided ".
           "handle has a different PHID ('%s'). The handle must correspond ".
           "to the policy identifier.",
           $policy_identifier,
           $handle_phid));
     }
 
     $policy = id(new PhabricatorPolicy())
       ->setPHID($policy_identifier)
       ->setHref($handle->getURI());
 
     $phid_type = phid_get_type($policy_identifier);
     switch ($phid_type) {
       case PhabricatorProjectProjectPHIDType::TYPECONST:
         $policy
           ->setType(PhabricatorPolicyType::TYPE_PROJECT)
           ->setName($handle->getName())
           ->setIcon($handle->getIcon());
         break;
       case PhabricatorPeopleUserPHIDType::TYPECONST:
         $policy->setType(PhabricatorPolicyType::TYPE_USER);
         $policy->setName($handle->getFullName());
         break;
       case PhabricatorPolicyPHIDTypePolicy::TYPECONST:
         // TODO: This creates a weird handle-based version of a rule policy.
         // It behaves correctly, but can't be applied since it doesn't have
         // any rules. It is used to render transactions, and might need some
         // cleanup.
         break;
       default:
         $policy->setType(PhabricatorPolicyType::TYPE_MASKED);
         $policy->setName($handle->getFullName());
         break;
     }
 
     $policy->makeEphemeral();
 
     return $policy;
   }
 
   public function setType($type) {
     $this->type = $type;
     return $this;
   }
 
   public function getType() {
     if (!$this->type) {
       return PhabricatorPolicyType::TYPE_CUSTOM;
     }
     return $this->type;
   }
 
   public function setName($name) {
     $this->name = $name;
     return $this;
   }
 
   public function getName() {
     if (!$this->name) {
       return pht('Custom Policy');
     }
     return $this->name;
   }
 
   public function setShortName($short_name) {
     $this->shortName = $short_name;
     return $this;
   }
 
   public function getShortName() {
     if ($this->shortName) {
       return $this->shortName;
     }
     return $this->getName();
   }
 
   public function setHref($href) {
     $this->href = $href;
     return $this;
   }
 
   public function getHref() {
     return $this->href;
   }
 
   public function setWorkflow($workflow) {
     $this->workflow = $workflow;
     return $this;
   }
 
   public function getWorkflow() {
     return $this->workflow;
   }
 
   public function setIcon($icon) {
     $this->icon = $icon;
     return $this;
   }
 
   public function getIcon() {
     if ($this->icon) {
       return $this->icon;
     }
 
     switch ($this->getType()) {
       case PhabricatorPolicyType::TYPE_GLOBAL:
         static $map = array(
           PhabricatorPolicies::POLICY_PUBLIC  => 'fa-globe',
           PhabricatorPolicies::POLICY_USER    => 'fa-users',
           PhabricatorPolicies::POLICY_ADMIN   => 'fa-eye',
           PhabricatorPolicies::POLICY_NOONE   => 'fa-ban',
         );
         return idx($map, $this->getPHID(), 'fa-question-circle');
       case PhabricatorPolicyType::TYPE_USER:
         return 'fa-user';
       case PhabricatorPolicyType::TYPE_PROJECT:
         return 'fa-briefcase';
       case PhabricatorPolicyType::TYPE_CUSTOM:
       case PhabricatorPolicyType::TYPE_MASKED:
         return 'fa-certificate';
       default:
         return 'fa-question-circle';
     }
   }
 
   public function getSortKey() {
     return sprintf(
       '%02d%s',
       PhabricatorPolicyType::getPolicyTypeOrder($this->getType()),
       $this->getSortName());
   }
 
   private function getSortName() {
     if ($this->getType() == PhabricatorPolicyType::TYPE_GLOBAL) {
       static $map = array(
         PhabricatorPolicies::POLICY_PUBLIC  => 0,
         PhabricatorPolicies::POLICY_USER    => 1,
         PhabricatorPolicies::POLICY_ADMIN   => 2,
         PhabricatorPolicies::POLICY_NOONE   => 3,
       );
       return idx($map, $this->getPHID());
     }
     return $this->getName();
   }
 
   public static function getPolicyExplanation(
     PhabricatorUser $viewer,
     $policy) {
 
     $type = phid_get_type($policy);
     if ($type === PhabricatorProjectProjectPHIDType::TYPECONST) {
       $handle = id(new PhabricatorHandleQuery())
         ->setViewer($viewer)
         ->withPHIDs(array($policy))
         ->executeOne();
 
       return pht(
         'Members of the project "%s" can take this action.',
         $handle->getFullName());
     }
 
     return self::getOpaquePolicyExplanation($viewer, $policy);
   }
 
   public static function getOpaquePolicyExplanation(
     PhabricatorUser $viewer,
     $policy) {
 
     $rule = PhabricatorPolicyQuery::getObjectPolicyRule($policy);
     if ($rule) {
       return $rule->getPolicyExplanation();
     }
 
     switch ($policy) {
       case PhabricatorPolicies::POLICY_PUBLIC:
         return pht(
           'This object is public and can be viewed by anyone, even if they '.
           'do not have an account on this server.');
       case PhabricatorPolicies::POLICY_USER:
         return pht('Logged in users can take this action.');
       case PhabricatorPolicies::POLICY_ADMIN:
         return pht('Administrators can take this action.');
       case PhabricatorPolicies::POLICY_NOONE:
         return pht('By default, no one can take this action.');
       default:
         $handle = id(new PhabricatorHandleQuery())
           ->setViewer($viewer)
           ->withPHIDs(array($policy))
           ->executeOne();
 
         $type = phid_get_type($policy);
         if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) {
           return pht(
             'Members of a particular project can take this action. (You '.
             'can not see this object, so the name of this project is '.
             'restricted.)');
         } else if ($type == PhabricatorPeopleUserPHIDType::TYPECONST) {
           return pht(
             '%s can take this action.',
             $handle->getFullName());
         } else if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) {
           return pht(
             'This object has a custom policy controlling who can take this '.
             'action.');
         } else {
           return pht(
             'This object has an unknown or invalid policy setting ("%s").',
             $policy);
         }
     }
   }
 
   public function getFullName() {
     switch ($this->getType()) {
       case PhabricatorPolicyType::TYPE_PROJECT:
         return pht('Members of Project: %s', $this->getName());
       case PhabricatorPolicyType::TYPE_MASKED:
         return pht('Other: %s', $this->getName());
       case PhabricatorPolicyType::TYPE_USER:
         return pht('Only User: %s', $this->getName());
       default:
         return $this->getName();
     }
   }
 
   public function newRef(PhabricatorUser $viewer) {
     return id(new PhabricatorPolicyRef())
       ->setViewer($viewer)
       ->setPolicy($this);
   }
 
   public function isProjectPolicy() {
     return ($this->getType() === PhabricatorPolicyType::TYPE_PROJECT);
   }
 
   public function isCustomPolicy() {
     return ($this->getType() === PhabricatorPolicyType::TYPE_CUSTOM);
   }
 
   public function isMaskedPolicy() {
     return ($this->getType() === PhabricatorPolicyType::TYPE_MASKED);
   }
 
   /**
    * Return a list of custom rule classes (concrete subclasses of
    * @{class:PhabricatorPolicyRule}) this policy uses.
    *
    * @return list<string> List of class names.
    */
   public function getCustomRuleClasses() {
     $classes = array();
 
     foreach ($this->getRules() as $rule) {
       if (!is_array($rule)) {
         // This rule is invalid. We'll reject it later, but don't need to
         // extract anything from it for now.
         continue;
       }
 
       $class = idx($rule, 'rule');
       try {
         if (class_exists($class)) {
           $classes[$class] = $class;
         }
       } catch (Exception $ex) {
         continue;
       }
     }
 
     return array_keys($classes);
   }
 
   /**
    * Return a list of all values used by a given rule class to implement this
    * policy. This is used to bulk load data (like project memberships) in order
    * to apply policy filters efficiently.
    *
-   * @param string Policy rule classname.
+   * @param string $rule_class Policy rule classname.
    * @return list<wild> List of values used in this policy.
    */
   public function getCustomRuleValues($rule_class) {
     $values = array();
     foreach ($this->getRules() as $rule) {
       if ($rule['rule'] == $rule_class) {
         $values[] = $rule['value'];
       }
     }
     return $values;
   }
 
   public function attachRuleObjects(array $objects) {
     $this->ruleObjects = $objects;
     return $this;
   }
 
   public function getRuleObjects() {
     return $this->assertAttached($this->ruleObjects);
   }
 
 
   /**
    * Return `true` if this policy is stronger (more restrictive) than some
    * other policy.
    *
    * Because policies are complicated, determining which policies are
    * "stronger" is not trivial. This method uses a very coarse working
    * definition of policy strength which is cheap to compute, unambiguous,
    * and intuitive in the common cases.
    *
    * This method returns `true` if the //class// of this policy is stronger
    * than the other policy, even if the policies are (or might be) the same in
    * practice. For example, "Members of Project X" is considered a stronger
    * policy than "All Users", even though "Project X" might (in some rare
    * cases) contain every user.
    *
    * Generally, the ordering here is:
    *
    *   - Public
    *   - All Users
    *   - (Everything Else)
    *   - No One
    *
    * In the "everything else" bucket, we can't make any broad claims about
    * which policy is stronger (and we especially can't make those claims
    * cheaply).
    *
    * Even if we fully evaluated each policy, the two policies might be
    * "Members of X" and "Members of Y", each of which permits access to some
    * set of unique users. In this case, neither is strictly stronger than
    * the other.
    *
-   * @param PhabricatorPolicy Other policy.
+   * @param PhabricatorPolicy $other Other policy.
    * @return bool `true` if this policy is more restrictive than the other
    *  policy.
    */
   public function isStrongerThan(PhabricatorPolicy $other) {
     $this_policy = $this->getPHID();
     $other_policy = $other->getPHID();
 
     $strengths = array(
       PhabricatorPolicies::POLICY_PUBLIC => -2,
       PhabricatorPolicies::POLICY_USER => -1,
       // (Default policies have strength 0.)
       PhabricatorPolicies::POLICY_NOONE => 1,
     );
 
     $this_strength = idx($strengths, $this_policy, 0);
     $other_strength = idx($strengths, $other_policy, 0);
 
     return ($this_strength > $other_strength);
   }
 
   public function isStrongerThanOrEqualTo(PhabricatorPolicy $other) {
     $this_policy = $this->getPHID();
     $other_policy = $other->getPHID();
 
     if ($this_policy === $other_policy) {
       return true;
     }
 
     return $this->isStrongerThan($other);
   }
 
   public function isValidPolicyForEdit() {
     return $this->getType() !== PhabricatorPolicyType::TYPE_MASKED;
   }
 
   public static function getSpecialRules(
     PhabricatorPolicyInterface $object,
     PhabricatorUser $viewer,
     $capability,
     $active_only) {
 
     $exceptions = array();
     if ($object instanceof PhabricatorPolicyCodexInterface) {
       $codex = id(PhabricatorPolicyCodex::newFromObject($object, $viewer))
         ->setCapability($capability);
       $rules = $codex->getPolicySpecialRuleDescriptions();
 
       foreach ($rules as $rule) {
         $is_active = $rule->getIsActive();
         if ($is_active) {
           $rule_capabilities = $rule->getCapabilities();
           if ($rule_capabilities) {
             if (!in_array($capability, $rule_capabilities)) {
               $is_active = false;
             }
           }
         }
 
         if (!$is_active && $active_only) {
           continue;
         }
 
         $description = $rule->getDescription();
 
         if (!$is_active) {
           $description = phutil_tag(
             'span',
             array(
               'class' => 'phui-policy-section-view-inactive-rule',
             ),
             $description);
         }
 
         $exceptions[] = $description;
       }
     }
 
     if (!$exceptions) {
       if (method_exists($object, 'describeAutomaticCapability')) {
         $exceptions = (array)$object->describeAutomaticCapability($capability);
         $exceptions = array_filter($exceptions);
       }
     }
 
     return $exceptions;
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
     );
   }
 
   public function getPolicy($capability) {
     // NOTE: We implement policies only so we can comply with the interface.
     // The actual query skips them, as enforcing policies on policies seems
     // perilous and isn't currently required by the application.
     return PhabricatorPolicies::POLICY_PUBLIC;
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return false;
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
 
     $this->delete();
   }
 
 
 }
diff --git a/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php b/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php
index 48eabf1545..8294f6832e 100644
--- a/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php
+++ b/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php
@@ -1,611 +1,611 @@
 <?php
 
 /**
  * Run pull commands on local working copies to keep them up to date. This
  * daemon handles all repository types.
  *
  * By default, the daemon pulls **every** repository. If you want it to be
  * responsible for only some repositories, you can launch it with a list of
  * repositories:
  *
  *   ./phd launch repositorypulllocal -- X Q Z
  *
  * You can also launch a daemon which is responsible for all //but// one or
  * more repositories:
  *
  *   ./phd launch repositorypulllocal -- --not A --not B
  *
  * If you have a very large number of repositories and some aren't being pulled
  * as frequently as you'd like, you can either change the pull frequency of
  * the less-important repositories to a larger number (so the daemon will skip
  * them more often) or launch one daemon for all the less-important repositories
  * and one for the more important repositories (or one for each more important
  * repository).
  *
  * @task pull   Pulling Repositories
  */
 final class PhabricatorRepositoryPullLocalDaemon
   extends PhabricatorDaemon {
 
   private $statusMessageCursor = 0;
 
 /* -(  Pulling Repositories  )----------------------------------------------- */
 
 
   /**
    * @task pull
    */
   protected function run() {
     $argv = $this->getArgv();
     array_unshift($argv, __CLASS__);
     $args = new PhutilArgumentParser($argv);
     $args->parse(
       array(
         array(
           'name'      => 'no-discovery',
           'help'      => pht('Pull only, without discovering commits.'),
         ),
         array(
           'name'      => 'not',
           'param'     => 'repository',
           'repeat'    => true,
           'help'      => pht('Do not pull __repository__.'),
         ),
         array(
           'name'      => 'repositories',
           'wildcard'  => true,
           'help'      => pht('Pull specific __repositories__ instead of all.'),
         ),
       ));
 
     $no_discovery   = $args->getArg('no-discovery');
     $include = $args->getArg('repositories');
     $exclude = $args->getArg('not');
 
     // Each repository has an individual pull frequency; after we pull it,
     // wait that long to pull it again. When we start up, try to pull everything
     // serially.
     $retry_after = array();
 
     $min_sleep = 15;
     $max_sleep = phutil_units('5 minutes in seconds');
     $max_futures = 4;
     $futures = array();
     $queue = array();
 
     $future_pool = new FuturePool();
 
     $future_pool->getIteratorTemplate()
       ->setUpdateInterval($min_sleep);
 
     $sync_wait = phutil_units('2 minutes in seconds');
     $last_sync = array();
 
     while (!$this->shouldExit()) {
       PhabricatorCaches::destroyRequestCache();
       $device = AlmanacKeys::getLiveDevice();
 
       $pullable = $this->loadPullableRepositories($include, $exclude, $device);
 
       // If any repositories have the NEEDS_UPDATE flag set, pull them
       // as soon as possible.
       $need_update_messages = $this->loadRepositoryUpdateMessages(true);
       foreach ($need_update_messages as $message) {
         $repo = idx($pullable, $message->getRepositoryID());
         if (!$repo) {
           continue;
         }
 
         $this->log(
           pht(
             'Got an update message for repository "%s"!',
             $repo->getMonogram()));
 
         $retry_after[$message->getRepositoryID()] = time();
       }
 
       if ($device) {
         $unsynchronized = $this->loadUnsynchronizedRepositories($device);
         $now = PhabricatorTime::getNow();
         foreach ($unsynchronized as $repository) {
           $id = $repository->getID();
 
           $this->log(
             pht(
               'Cluster repository ("%s") is out of sync on this node ("%s").',
               $repository->getDisplayName(),
               $device->getName()));
 
           // Don't let out-of-sync conditions trigger updates too frequently,
           // since we don't want to get trapped in a death spiral if sync is
           // failing.
           $sync_at = idx($last_sync, $id, 0);
           $wait_duration = ($now - $sync_at);
           if ($wait_duration < $sync_wait) {
             $this->log(
               pht(
                 'Skipping forced out-of-sync update because the last update '.
                 'was too recent (%s seconds ago).',
                 $wait_duration));
             continue;
           }
 
           $last_sync[$id] = $now;
           $retry_after[$id] = $now;
         }
       }
 
       // If any repositories were deleted, remove them from the retry timer map
       // so we don't end up with a retry timer that never gets updated and
       // causes us to sleep for the minimum amount of time.
       $retry_after = array_select_keys(
         $retry_after,
         array_keys($pullable));
 
       // Figure out which repositories we need to queue for an update.
       foreach ($pullable as $id => $repository) {
         $now = PhabricatorTime::getNow();
         $display_name = $repository->getDisplayName();
 
         if (isset($futures[$id])) {
           $this->log(
             pht(
               'Repository "%s" is currently updating.',
               $display_name));
           continue;
         }
 
         if (isset($queue[$id])) {
           $this->log(
             pht(
               'Repository "%s" is already queued.',
               $display_name));
           continue;
         }
 
         $after = idx($retry_after, $id);
         if (!$after) {
           $smart_wait = $repository->loadUpdateInterval($min_sleep);
           $last_update = $this->loadLastUpdate($repository);
 
           $after = $last_update + $smart_wait;
           $retry_after[$id] = $after;
 
           $this->log(
             pht(
               'Scheduling repository "%s" with an update window of %s '.
               'second(s). Last update was %s second(s) ago.',
               $display_name,
               new PhutilNumber($smart_wait),
               new PhutilNumber($now - $last_update)));
         }
 
         if ($after > time()) {
           $this->log(
             pht(
               'Repository "%s" is not due for an update for %s second(s).',
               $display_name,
               new PhutilNumber($after - $now)));
           continue;
         }
 
         $this->log(
           pht(
             'Scheduling repository "%s" for an update (%s seconds overdue).',
             $display_name,
             new PhutilNumber($now - $after)));
 
         $queue[$id] = $after;
       }
 
       // Process repositories in the order they became candidates for updates.
       asort($queue);
 
       // Dequeue repositories until we hit maximum parallelism.
       while ($queue && (count($futures) < $max_futures)) {
         foreach ($queue as $id => $time) {
           $repository = idx($pullable, $id);
           if (!$repository) {
             $this->log(
               pht('Repository %s is no longer pullable; skipping.', $id));
             unset($queue[$id]);
             continue;
           }
 
           $display_name = $repository->getDisplayName();
           $this->log(
             pht(
               'Starting update for repository "%s".',
               $display_name));
 
           unset($queue[$id]);
 
           $future = $this->buildUpdateFuture(
             $repository,
             $no_discovery);
 
           $futures[$id] = $future->getFutureKey();
 
           $future_pool->addFuture($future);
           break;
         }
       }
 
       if ($queue) {
         $this->log(
           pht(
             'Not enough process slots to schedule the other %s '.
             'repository(s) for updates yet.',
             phutil_count($queue)));
       }
 
       if ($future_pool->hasFutures()) {
         while ($future_pool->hasFutures()) {
           $future = $future_pool->resolve();
 
           $this->stillWorking();
 
           if ($future === null) {
             $this->log(pht('Waiting for updates to complete...'));
 
             if ($this->loadRepositoryUpdateMessages()) {
               $this->log(pht('Interrupted by pending updates!'));
               break;
             }
 
             continue;
           }
 
           $future_key = $future->getFutureKey();
           $repository_id = null;
           foreach ($futures as $id => $key) {
             if ($key === $future_key) {
               $repository_id = $id;
               unset($futures[$id]);
               break;
             }
           }
 
           $retry_after[$repository_id] = $this->resolveUpdateFuture(
             $pullable[$repository_id],
             $future,
             $min_sleep);
 
           // We have a free slot now, so go try to fill it.
           break;
         }
 
         // Jump back into prioritization if we had any futures to deal with.
         continue;
       }
 
       $should_hibernate = $this->waitForUpdates($max_sleep, $retry_after);
       if ($should_hibernate) {
         break;
       }
     }
 
   }
 
 
   /**
    * @task pull
    */
   private function buildUpdateFuture(
     PhabricatorRepository $repository,
     $no_discovery) {
 
     $bin = dirname(phutil_get_library_root('phabricator')).'/bin/repository';
 
     $flags = array();
     if ($no_discovery) {
       $flags[] = '--no-discovery';
     }
 
     $monogram = $repository->getMonogram();
     $future = new ExecFuture('%s update %Ls -- %s', $bin, $flags, $monogram);
 
     // Sometimes, the underlying VCS commands will hang indefinitely. We've
     // observed this occasionally with GitHub, and other users have observed
     // it with other VCS servers.
 
     // To limit the damage this can cause, kill the update out after a
     // reasonable amount of time, under the assumption that it has hung.
 
     // Since it's hard to know what a "reasonable" amount of time is given that
     // users may be downloading a repository full of pirated movies over a
     // potato, these limits are fairly generous. Repositories exceeding these
     // limits can be manually pulled with `bin/repository update X`, which can
     // just run for as long as it wants.
 
     if ($repository->isImporting()) {
       $timeout = phutil_units('4 hours in seconds');
     } else {
       $timeout = phutil_units('15 minutes in seconds');
     }
 
     $future->setTimeout($timeout);
 
     // The default TERM inherited by this process is "unknown", which causes PHP
     // to produce a warning upon startup.  Override it to squash this output to
     // STDERR.
     $future->updateEnv('TERM', 'dumb');
 
     return $future;
   }
 
 
   /**
    * Check for repositories that should be updated immediately.
    *
    * With the `$consume` flag, an internal cursor will also be incremented so
    * that these messages are not returned by subsequent calls.
    *
-   * @param bool Pass `true` to consume these messages, so the process will
-   *   not see them again.
+   * @param bool? $consume Pass `true` to consume these messages, so the
+   *   process will not see them again.
    * @return list<wild> Pending update messages.
    *
    * @task pull
    */
   private function loadRepositoryUpdateMessages($consume = false) {
     $type_need_update = PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE;
     $messages = id(new PhabricatorRepositoryStatusMessage())->loadAllWhere(
       'statusType = %s AND id > %d',
       $type_need_update,
       $this->statusMessageCursor);
 
     // Keep track of messages we've seen so that we don't load them again.
     // If we reload messages, we can get stuck a loop if we have a failing
     // repository: we update immediately in response to the message, but do
     // not clear the message because the update does not succeed. We then
     // immediately retry. Instead, messages are only permitted to trigger
     // an immediate update once.
 
     if ($consume) {
       foreach ($messages as $message) {
         $this->statusMessageCursor = max(
           $this->statusMessageCursor,
           $message->getID());
       }
     }
 
     return $messages;
   }
 
 
   /**
    * @task pull
    */
   private function loadLastUpdate(PhabricatorRepository $repository) {
     $table = new PhabricatorRepositoryStatusMessage();
     $conn = $table->establishConnection('r');
 
     $epoch = queryfx_one(
       $conn,
       'SELECT MAX(epoch) last_update FROM %T
         WHERE repositoryID = %d
           AND statusType IN (%Ls)',
       $table->getTableName(),
       $repository->getID(),
       array(
         PhabricatorRepositoryStatusMessage::TYPE_INIT,
         PhabricatorRepositoryStatusMessage::TYPE_FETCH,
       ));
 
     if ($epoch) {
       return (int)$epoch['last_update'];
     }
 
     return PhabricatorTime::getNow();
   }
 
   /**
    * @task pull
    */
   private function loadPullableRepositories(
     array $include,
     array $exclude,
     AlmanacDevice $device = null) {
 
     $query = id(new PhabricatorRepositoryQuery())
       ->setViewer($this->getViewer());
 
     if ($include) {
       $query->withIdentifiers($include);
     }
 
     $repositories = $query->execute();
     $repositories = mpull($repositories, null, 'getPHID');
 
     if ($include) {
       $map = $query->getIdentifierMap();
       foreach ($include as $identifier) {
         if (empty($map[$identifier])) {
           throw new Exception(
             pht(
               'No repository "%s" exists!',
               $identifier));
         }
       }
     }
 
     if ($exclude) {
       $xquery = id(new PhabricatorRepositoryQuery())
         ->setViewer($this->getViewer())
         ->withIdentifiers($exclude);
 
       $excluded_repos = $xquery->execute();
       $xmap = $xquery->getIdentifierMap();
 
       foreach ($exclude as $identifier) {
         if (empty($xmap[$identifier])) {
           throw new Exception(
             pht(
               'No repository "%s" exists!',
               $identifier));
         }
       }
 
       foreach ($excluded_repos as $excluded_repo) {
         unset($repositories[$excluded_repo->getPHID()]);
       }
     }
 
     foreach ($repositories as $key => $repository) {
       if (!$repository->isTracked()) {
         unset($repositories[$key]);
       }
     }
 
     $viewer = $this->getViewer();
 
     $filter = id(new DiffusionLocalRepositoryFilter())
       ->setViewer($viewer)
       ->setDevice($device)
       ->setRepositories($repositories);
 
     $repositories = $filter->execute();
 
     foreach ($filter->getRejectionReasons() as $reason) {
       $this->log($reason);
     }
 
     // Shuffle the repositories, then re-key the array since shuffle()
     // discards keys. This is mostly for startup, we'll use soft priorities
     // later.
     shuffle($repositories);
     $repositories = mpull($repositories, null, 'getID');
 
     return $repositories;
   }
 
 
   /**
    * @task pull
    */
   private function resolveUpdateFuture(
     PhabricatorRepository $repository,
     ExecFuture $future,
     $min_sleep) {
 
     $display_name = $repository->getDisplayName();
 
     $this->log(pht('Resolving update for "%s".', $display_name));
 
     try {
       list($stdout, $stderr) = $future->resolvex();
     } catch (Exception $ex) {
       $proxy = new PhutilProxyException(
         pht(
           'Error while updating the "%s" repository.',
           $display_name),
         $ex);
       phlog($proxy);
 
       $smart_wait = $repository->loadUpdateInterval($min_sleep);
       return PhabricatorTime::getNow() + $smart_wait;
     }
 
     if (strlen($stderr)) {
       $stderr_msg = pht(
         'Unexpected output while updating repository "%s": %s',
         $display_name,
         $stderr);
       phlog($stderr_msg);
     }
 
     $smart_wait = $repository->loadUpdateInterval($min_sleep);
 
     $this->log(
       pht(
         'Based on activity in repository "%s", considering a wait of %s '.
         'seconds before update.',
         $display_name,
         new PhutilNumber($smart_wait)));
 
     return PhabricatorTime::getNow() + $smart_wait;
   }
 
 
 
   /**
    * Sleep for a short period of time, waiting for update messages from the
    *
    *
    * @task pull
    */
   private function waitForUpdates($min_sleep, array $retry_after) {
     $this->log(
       pht('No repositories need updates right now, sleeping...'));
 
     $sleep_until = time() + $min_sleep;
     if ($retry_after) {
       $sleep_until = min($sleep_until, min($retry_after));
     }
 
     while (($sleep_until - time()) > 0) {
       $sleep_duration = ($sleep_until - time());
 
       if ($this->shouldHibernate($sleep_duration)) {
         return true;
       }
 
       $this->log(
         pht(
           'Sleeping for %s more second(s)...',
           new PhutilNumber($sleep_duration)));
 
       $this->sleep(1);
 
       if ($this->shouldExit()) {
         $this->log(pht('Awakened from sleep by graceful shutdown!'));
         return false;
       }
 
       if ($this->loadRepositoryUpdateMessages()) {
         $this->log(pht('Awakened from sleep by pending updates!'));
         break;
       }
     }
 
     return false;
   }
 
   private function loadUnsynchronizedRepositories(AlmanacDevice $device) {
     $viewer = $this->getViewer();
     $table = new PhabricatorRepositoryWorkingCopyVersion();
     $conn = $table->establishConnection('r');
 
     $our_versions = queryfx_all(
       $conn,
       'SELECT repositoryPHID, repositoryVersion FROM %R WHERE devicePHID = %s',
       $table,
       $device->getPHID());
     $our_versions = ipull($our_versions, 'repositoryVersion', 'repositoryPHID');
 
     $max_versions = queryfx_all(
       $conn,
       'SELECT repositoryPHID, MAX(repositoryVersion) maxVersion FROM %R
         GROUP BY repositoryPHID',
       $table);
     $max_versions = ipull($max_versions, 'maxVersion', 'repositoryPHID');
 
     $unsynchronized_phids = array();
     foreach ($max_versions as $repository_phid => $max_version) {
       $our_version = idx($our_versions, $repository_phid);
       if (($our_version === null) || ($our_version < $max_version)) {
         $unsynchronized_phids[] = $repository_phid;
       }
     }
 
     if (!$unsynchronized_phids) {
       return array();
     }
 
     return id(new PhabricatorRepositoryQuery())
       ->setViewer($viewer)
       ->withPHIDs($unsynchronized_phids)
       ->execute();
   }
 
 }
diff --git a/src/applications/repository/engine/PhabricatorRepositoryDiscoveryEngine.php b/src/applications/repository/engine/PhabricatorRepositoryDiscoveryEngine.php
index 8f5b346ecd..7d66868f50 100644
--- a/src/applications/repository/engine/PhabricatorRepositoryDiscoveryEngine.php
+++ b/src/applications/repository/engine/PhabricatorRepositoryDiscoveryEngine.php
@@ -1,931 +1,931 @@
 <?php
 
 /**
  * @task discover   Discovering Repositories
  * @task svn        Discovering Subversion Repositories
  * @task git        Discovering Git Repositories
  * @task hg         Discovering Mercurial Repositories
  * @task internal   Internals
  */
 final class PhabricatorRepositoryDiscoveryEngine
   extends PhabricatorRepositoryEngine {
 
   private $repairMode;
   private $commitCache = array();
   private $workingSet = array();
 
   const MAX_COMMIT_CACHE_SIZE = 65535;
 
 
 /* -(  Discovering Repositories  )------------------------------------------- */
 
 
   public function setRepairMode($repair_mode) {
     $this->repairMode = $repair_mode;
     return $this;
   }
 
 
   public function getRepairMode() {
     return $this->repairMode;
   }
 
 
   /**
    * @task discovery
    */
   public function discoverCommits() {
     $repository = $this->getRepository();
 
     $lock = $this->newRepositoryLock($repository, 'repo.look', false);
 
     try {
       $lock->lock();
     } catch (PhutilLockException $ex) {
       throw new DiffusionDaemonLockException(
         pht(
           'Another process is currently discovering repository "%s", '.
           'skipping discovery.',
           $repository->getDisplayName()));
     }
 
     try {
       $result = $this->discoverCommitsWithLock();
     } catch (Exception $ex) {
       $lock->unlock();
       throw $ex;
     }
 
     $lock->unlock();
 
     return $result;
   }
 
   private function discoverCommitsWithLock() {
     $repository = $this->getRepository();
     $viewer = $this->getViewer();
 
     $vcs = $repository->getVersionControlSystem();
     switch ($vcs) {
       case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
         $refs = $this->discoverSubversionCommits();
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
         $refs = $this->discoverMercurialCommits();
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
         $refs = $this->discoverGitCommits();
         break;
       default:
         throw new Exception(pht("Unknown VCS '%s'!", $vcs));
     }
 
     if ($this->isInitialImport($refs)) {
       $this->log(
         pht(
           'Discovered more than %s commit(s) in an empty repository, '.
           'marking repository as importing.',
           new PhutilNumber(PhabricatorRepository::IMPORT_THRESHOLD)));
 
       $repository->markImporting();
     }
 
     // Clear the working set cache.
     $this->workingSet = array();
 
     $task_priority = $this->getImportTaskPriority($repository, $refs);
 
     // Record discovered commits and mark them in the cache.
     foreach ($refs as $ref) {
       $this->recordCommit(
         $repository,
         $ref->getIdentifier(),
         $ref->getEpoch(),
         $ref->getIsPermanent(),
         $ref->getParents(),
         $task_priority);
 
       $this->commitCache[$ref->getIdentifier()] = true;
     }
 
     $this->markUnreachableCommits($repository);
 
     $version = $this->getObservedVersion($repository);
     if ($version !== null) {
       id(new DiffusionRepositoryClusterEngine())
         ->setViewer($viewer)
         ->setRepository($repository)
         ->synchronizeWorkingCopyAfterDiscovery($version);
     }
 
     return $refs;
   }
 
 
 /* -(  Discovering Git Repositories  )--------------------------------------- */
 
 
   /**
    * @task git
    */
   private function discoverGitCommits() {
     $repository = $this->getRepository();
     $publisher = $repository->newPublisher();
 
     $heads = id(new DiffusionLowLevelGitRefQuery())
       ->setRepository($repository)
       ->execute();
 
     if (!$heads) {
       // This repository has no heads at all, so we don't need to do
       // anything. Generally, this means the repository is empty.
       return array();
     }
 
     $this->log(
       pht(
         'Discovering commits in repository "%s".',
         $repository->getDisplayName()));
 
     $ref_lists = array();
 
     $head_groups = $this->getRefGroupsForDiscovery($heads);
     foreach ($head_groups as $head_group) {
 
       $group_identifiers = mpull($head_group, 'getCommitIdentifier');
       $group_identifiers = array_fuse($group_identifiers);
       $this->fillCommitCache($group_identifiers);
 
       foreach ($head_group as $ref) {
         $name = $ref->getShortName();
         $commit = $ref->getCommitIdentifier();
 
         $this->log(
           pht(
             'Examining "%s" (%s) at "%s".',
             $name,
             $ref->getRefType(),
             $commit));
 
         if (!$repository->shouldTrackRef($ref)) {
           $this->log(pht('Skipping, ref is untracked.'));
           continue;
         }
 
         if ($this->isKnownCommit($commit)) {
           $this->log(pht('Skipping, HEAD is known.'));
           continue;
         }
 
         // In Git, it's possible to tag anything. We just skip tags that don't
         // point to a commit. See T11301.
         $fields = $ref->getRawFields();
         $ref_type = idx($fields, 'objecttype');
         $tag_type = idx($fields, '*objecttype');
         if ($ref_type != 'commit' && $tag_type != 'commit') {
           $this->log(pht('Skipping, this is not a commit.'));
           continue;
         }
 
         $this->log(pht('Looking for new commits.'));
 
         $head_refs = $this->discoverStreamAncestry(
           new PhabricatorGitGraphStream($repository, $commit),
           $commit,
           $publisher->isPermanentRef($ref));
 
         $this->didDiscoverRefs($head_refs);
 
         $ref_lists[] = $head_refs;
       }
     }
 
     $refs = array_mergev($ref_lists);
 
     return $refs;
   }
 
   /**
    * @task git
    */
   private function getRefGroupsForDiscovery(array $heads) {
     $heads = $this->sortRefs($heads);
 
     // See T13593. We hold a commit cache with a fixed maximum size. Split the
     // refs into chunks no larger than the cache size, so we don't overflow the
     // cache when testing them.
 
     $array_iterator = new ArrayIterator($heads);
 
     $chunk_iterator = new PhutilChunkedIterator(
       $array_iterator,
       self::MAX_COMMIT_CACHE_SIZE);
 
     return $chunk_iterator;
   }
 
 
 /* -(  Discovering Subversion Repositories  )-------------------------------- */
 
 
   /**
    * @task svn
    */
   private function discoverSubversionCommits() {
     $repository = $this->getRepository();
 
     if (!$repository->isHosted()) {
       $this->verifySubversionRoot($repository);
     }
 
     $upper_bound = null;
     $limit = 1;
     $refs = array();
     do {
       // Find all the unknown commits on this path. Note that we permit
       // importing an SVN subdirectory rather than the entire repository, so
       // commits may be nonsequential.
 
       if ($upper_bound === null) {
         $at_rev = 'HEAD';
       } else {
         $at_rev = ($upper_bound - 1);
       }
 
       try {
         list($xml, $stderr) = $repository->execxRemoteCommand(
           'log --xml --quiet --limit %d %s',
           $limit,
           $repository->getSubversionBaseURI($at_rev));
       } catch (CommandException $ex) {
         $stderr = $ex->getStderr();
         if (preg_match('/(path|File) not found/', $stderr)) {
           // We've gone all the way back through history and this path was not
           // affected by earlier commits.
           break;
         }
         throw $ex;
       }
 
       $xml = phutil_utf8ize($xml);
       $log = new SimpleXMLElement($xml);
       foreach ($log->logentry as $entry) {
         $identifier = (int)$entry['revision'];
         $epoch = (int)strtotime((string)$entry->date[0]);
         $refs[$identifier] = id(new PhabricatorRepositoryCommitRef())
           ->setIdentifier($identifier)
           ->setEpoch($epoch)
           ->setIsPermanent(true);
 
         if ($upper_bound === null) {
           $upper_bound = $identifier;
         } else {
           $upper_bound = min($upper_bound, $identifier);
         }
       }
 
       // Discover 2, 4, 8, ... 256 logs at a time. This allows us to initially
       // import large repositories fairly quickly, while pulling only as much
       // data as we need in the common case (when we've already imported the
       // repository and are just grabbing one commit at a time).
       $limit = min($limit * 2, 256);
 
     } while ($upper_bound > 1 && !$this->isKnownCommit($upper_bound));
 
     krsort($refs);
     while ($refs && $this->isKnownCommit(last($refs)->getIdentifier())) {
       array_pop($refs);
     }
     $refs = array_reverse($refs);
 
     $this->didDiscoverRefs($refs);
 
     return $refs;
   }
 
 
   private function verifySubversionRoot(PhabricatorRepository $repository) {
     list($xml) = $repository->execxRemoteCommand(
       'info --xml %s',
       $repository->getSubversionPathURI());
 
     $xml = phutil_utf8ize($xml);
     $xml = new SimpleXMLElement($xml);
 
     $remote_root = (string)($xml->entry[0]->repository[0]->root[0]);
     $expect_root = $repository->getSubversionPathURI();
 
     $normal_type_svn = ArcanistRepositoryURINormalizer::TYPE_SVN;
 
     $remote_normal = id(new ArcanistRepositoryURINormalizer(
       $normal_type_svn,
       $remote_root))->getNormalizedPath();
 
     $expect_normal = id(new ArcanistRepositoryURINormalizer(
       $normal_type_svn,
       $expect_root))->getNormalizedPath();
 
     if ($remote_normal != $expect_normal) {
       throw new Exception(
         pht(
           'Repository "%s" does not have a correctly configured remote URI. '.
           'The remote URI for a Subversion repository MUST point at the '.
           'repository root. The root for this repository is "%s", but the '.
           'configured URI is "%s". To resolve this error, set the remote URI '.
           'to point at the repository root. If you want to import only part '.
           'of a Subversion repository, use the "Import Only" option.',
           $repository->getDisplayName(),
           $remote_root,
           $expect_root));
     }
   }
 
 
 /* -(  Discovering Mercurial Repositories  )--------------------------------- */
 
 
   /**
    * @task hg
    */
   private function discoverMercurialCommits() {
     $repository = $this->getRepository();
 
     $branches = id(new DiffusionLowLevelMercurialBranchesQuery())
       ->setRepository($repository)
       ->execute();
 
     $this->fillCommitCache(mpull($branches, 'getCommitIdentifier'));
 
     $refs = array();
     foreach ($branches as $branch) {
       // NOTE: Mercurial branches may have multiple heads, so the names may
       // not be unique.
       $name = $branch->getShortName();
       $commit = $branch->getCommitIdentifier();
 
       $this->log(pht('Examining branch "%s" head "%s".', $name, $commit));
       if (!$repository->shouldTrackBranch($name)) {
         $this->log(pht('Skipping, branch is untracked.'));
         continue;
       }
 
       if ($this->isKnownCommit($commit)) {
         $this->log(pht('Skipping, this head is a known commit.'));
         continue;
       }
 
       $this->log(pht('Looking for new commits.'));
 
       $branch_refs = $this->discoverStreamAncestry(
         new PhabricatorMercurialGraphStream($repository, $commit),
         $commit,
         $is_permanent = true);
 
       $this->didDiscoverRefs($branch_refs);
 
       $refs[] = $branch_refs;
     }
 
     return array_mergev($refs);
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   private function discoverStreamAncestry(
     PhabricatorRepositoryGraphStream $stream,
     $commit,
     $is_permanent) {
 
     $discover = array($commit);
     $graph = array();
     $seen = array();
 
     // Find all the reachable, undiscovered commits. Build a graph of the
     // edges.
     while ($discover) {
       $target = array_pop($discover);
 
       if (empty($graph[$target])) {
         $graph[$target] = array();
       }
 
       $parents = $stream->getParents($target);
       foreach ($parents as $parent) {
         if ($this->isKnownCommit($parent)) {
           continue;
         }
 
         $graph[$target][$parent] = true;
 
         if (empty($seen[$parent])) {
           $seen[$parent] = true;
           $discover[] = $parent;
         }
       }
     }
 
     // Now, sort them topologically.
     $commits = $this->reduceGraph($graph);
 
     $refs = array();
     foreach ($commits as $commit) {
       $epoch = $stream->getCommitDate($commit);
 
       // If the epoch doesn't fit into a uint32, treat it as though it stores
       // the current time. For discussion, see T11537.
       if ($epoch > 0xFFFFFFFF) {
         $epoch = PhabricatorTime::getNow();
       }
 
       // If the epoch is not present at all, treat it as though it stores the
       // value "0". For discussion, see T12062. This behavior is consistent
       // with the behavior of "git show".
       if (!strlen($epoch)) {
         $epoch = 0;
       }
 
       $refs[] = id(new PhabricatorRepositoryCommitRef())
         ->setIdentifier($commit)
         ->setEpoch($epoch)
         ->setIsPermanent($is_permanent)
         ->setParents($stream->getParents($commit));
     }
 
     return $refs;
   }
 
 
   private function reduceGraph(array $edges) {
     foreach ($edges as $commit => $parents) {
       $edges[$commit] = array_keys($parents);
     }
 
     $graph = new PhutilDirectedScalarGraph();
     $graph->addNodes($edges);
 
     $commits = $graph->getNodesInTopologicalOrder();
 
     // NOTE: We want the most ancestral nodes first, so we need to reverse the
     // list we get out of AbstractDirectedGraph.
     $commits = array_reverse($commits);
 
     return $commits;
   }
 
 
   private function isKnownCommit($identifier) {
     if (isset($this->commitCache[$identifier])) {
       return true;
     }
 
     if (isset($this->workingSet[$identifier])) {
       return true;
     }
 
     $this->fillCommitCache(array($identifier));
 
     return isset($this->commitCache[$identifier]);
   }
 
   private function fillCommitCache(array $identifiers) {
     if (!$identifiers) {
       return;
     }
 
     if ($this->repairMode) {
       // In repair mode, rediscover the entire repository, ignoring the
       // database state. The engine still maintains a local cache (the
       // "Working Set") but we just give up before looking in the database.
       return;
     }
 
     $max_size = self::MAX_COMMIT_CACHE_SIZE;
 
     // If we're filling more identifiers than would fit in the cache, ignore
     // the ones that don't fit. Because the cache is FIFO, overfilling it can
     // cause the entire cache to miss. See T12296.
     if (count($identifiers) > $max_size) {
       $identifiers = array_slice($identifiers, 0, $max_size);
     }
 
     // When filling the cache we ignore commits which have been marked as
     // unreachable, treating them as though they do not exist. When recording
     // commits later we'll revive commits that exist but are unreachable.
 
     $commits = id(new PhabricatorRepositoryCommit())->loadAllWhere(
       'repositoryID = %d AND commitIdentifier IN (%Ls)
         AND (importStatus & %d) != %d',
       $this->getRepository()->getID(),
       $identifiers,
       PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE,
       PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE);
 
     foreach ($commits as $commit) {
       $this->commitCache[$commit->getCommitIdentifier()] = true;
     }
 
     while (count($this->commitCache) > $max_size) {
       array_shift($this->commitCache);
     }
   }
 
   /**
    * Sort refs so we process permanent refs first. This makes the whole import
    * process a little cheaper, since we can publish these commits the first
    * time through rather than catching them in the refs step.
    *
    * @task internal
    *
-   * @param   list<DiffusionRepositoryRef> List of refs.
+   * @param   list<DiffusionRepositoryRef> $refs List of refs.
    * @return  list<DiffusionRepositoryRef> Sorted list of refs.
    */
   private function sortRefs(array $refs) {
     $repository = $this->getRepository();
     $publisher = $repository->newPublisher();
 
     $head_refs = array();
     $tail_refs = array();
     foreach ($refs as $ref) {
       if ($publisher->isPermanentRef($ref)) {
         $head_refs[] = $ref;
       } else {
         $tail_refs[] = $ref;
       }
     }
 
     return array_merge($head_refs, $tail_refs);
   }
 
 
   private function recordCommit(
     PhabricatorRepository $repository,
     $commit_identifier,
     $epoch,
     $is_permanent,
     array $parents,
     $task_priority) {
 
     $commit = new PhabricatorRepositoryCommit();
     $conn_w = $repository->establishConnection('w');
 
     // First, try to revive an existing unreachable commit (if one exists) by
     // removing the "unreachable" flag. If we succeed, we don't need to do
     // anything else: we already discovered this commit some time ago.
     queryfx(
       $conn_w,
       'UPDATE %T SET importStatus = (importStatus & ~%d)
         WHERE repositoryID = %d AND commitIdentifier = %s',
       $commit->getTableName(),
       PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE,
       $repository->getID(),
       $commit_identifier);
     if ($conn_w->getAffectedRows()) {
       $commit = $commit->loadOneWhere(
         'repositoryID = %d AND commitIdentifier = %s',
         $repository->getID(),
         $commit_identifier);
 
       // After reviving a commit, schedule new daemons for it.
       $this->didDiscoverCommit($repository, $commit, $epoch, $task_priority);
       return;
     }
 
     $commit->setRepositoryID($repository->getID());
     $commit->setCommitIdentifier($commit_identifier);
     $commit->setEpoch($epoch);
     if ($is_permanent) {
       $commit->setImportStatus(PhabricatorRepositoryCommit::IMPORTED_PERMANENT);
     }
 
     $data = new PhabricatorRepositoryCommitData();
 
     try {
       // If this commit has parents, look up their IDs. The parent commits
       // should always exist already.
 
       $parent_ids = array();
       if ($parents) {
         $parent_rows = queryfx_all(
           $conn_w,
           'SELECT id, commitIdentifier FROM %T
             WHERE commitIdentifier IN (%Ls) AND repositoryID = %d',
           $commit->getTableName(),
           $parents,
           $repository->getID());
 
         $parent_map = ipull($parent_rows, 'id', 'commitIdentifier');
 
         foreach ($parents as $parent) {
           if (empty($parent_map[$parent])) {
             throw new Exception(
               pht('Unable to identify parent "%s"!', $parent));
           }
           $parent_ids[] = $parent_map[$parent];
         }
       } else {
         // Write an explicit 0 so we can distinguish between "really no
         // parents" and "data not available".
         if (!$repository->isSVN()) {
           $parent_ids = array(0);
         }
       }
 
       $commit->openTransaction();
         $commit->save();
 
         $data->setCommitID($commit->getID());
         $data->save();
 
         foreach ($parent_ids as $parent_id) {
           queryfx(
             $conn_w,
             'INSERT IGNORE INTO %T (childCommitID, parentCommitID)
               VALUES (%d, %d)',
             PhabricatorRepository::TABLE_PARENTS,
             $commit->getID(),
             $parent_id);
         }
       $commit->saveTransaction();
 
       $this->didDiscoverCommit($repository, $commit, $epoch, $task_priority);
 
       if ($this->repairMode) {
         // Normally, the query should throw a duplicate key exception. If we
         // reach this in repair mode, we've actually performed a repair.
         $this->log(pht('Repaired commit "%s".', $commit_identifier));
       }
 
       PhutilEventEngine::dispatchEvent(
         new PhabricatorEvent(
           PhabricatorEventType::TYPE_DIFFUSION_DIDDISCOVERCOMMIT,
           array(
             'repository'  => $repository,
             'commit'      => $commit,
           )));
 
     } catch (AphrontDuplicateKeyQueryException $ex) {
       $commit->killTransaction();
       // Ignore. This can happen because we discover the same new commit
       // more than once when looking at history, or because of races or
       // data inconsistency or cosmic radiation; in any case, we're still
       // in a good state if we ignore the failure.
     }
   }
 
   private function didDiscoverCommit(
     PhabricatorRepository $repository,
     PhabricatorRepositoryCommit $commit,
     $epoch,
     $task_priority) {
 
     $this->queueCommitImportTask(
       $repository,
       $commit->getPHID(),
       $task_priority,
       $via = 'discovery');
 
     // Update the repository summary table.
     queryfx(
       $commit->establishConnection('w'),
       'INSERT INTO %T (repositoryID, size, lastCommitID, epoch)
         VALUES (%d, 1, %d, %d)
         ON DUPLICATE KEY UPDATE
           size = size + 1,
           lastCommitID =
             IF(VALUES(epoch) > epoch, VALUES(lastCommitID), lastCommitID),
           epoch = IF(VALUES(epoch) > epoch, VALUES(epoch), epoch)',
       PhabricatorRepository::TABLE_SUMMARY,
       $repository->getID(),
       $commit->getID(),
       $epoch);
   }
 
   private function didDiscoverRefs(array $refs) {
     foreach ($refs as $ref) {
       $this->workingSet[$ref->getIdentifier()] = true;
     }
   }
 
   private function isInitialImport(array $refs) {
     $commit_count = count($refs);
 
     if ($commit_count <= PhabricatorRepository::IMPORT_THRESHOLD) {
       // If we fetched a small number of commits, assume it's an initial
       // commit or a stack of a few initial commits.
       return false;
     }
 
     $viewer = $this->getViewer();
     $repository = $this->getRepository();
 
     $any_commits = id(new DiffusionCommitQuery())
       ->setViewer($viewer)
       ->withRepository($repository)
       ->setLimit(1)
       ->execute();
 
     if ($any_commits) {
       // If the repository already has commits, this isn't an import.
       return false;
     }
 
     return true;
   }
 
 
   private function getObservedVersion(PhabricatorRepository $repository) {
     if ($repository->isHosted()) {
       return null;
     }
 
     if ($repository->isGit()) {
       return $this->getGitObservedVersion($repository);
     }
 
     return null;
   }
 
   private function getGitObservedVersion(PhabricatorRepository $repository) {
     $refs = id(new DiffusionLowLevelGitRefQuery())
      ->setRepository($repository)
      ->execute();
     if (!$refs) {
       return null;
     }
 
     // In Git, the observed version is the most recently discovered commit
     // at any repository HEAD. It's possible for this to regress temporarily
     // if a branch is pushed and then deleted. This is acceptable because it
     // doesn't do anything meaningfully bad and will fix itself on the next
     // push.
 
     $ref_identifiers = mpull($refs, 'getCommitIdentifier');
     $ref_identifiers = array_fuse($ref_identifiers);
 
     $version = queryfx_one(
       $repository->establishConnection('w'),
       'SELECT MAX(id) version FROM %T WHERE repositoryID = %d
         AND commitIdentifier IN (%Ls)',
       id(new PhabricatorRepositoryCommit())->getTableName(),
       $repository->getID(),
       $ref_identifiers);
 
     if (!$version) {
       return null;
     }
 
     return (int)$version['version'];
   }
 
   private function markUnreachableCommits(PhabricatorRepository $repository) {
     if (!$repository->isGit() && !$repository->isHg()) {
       return;
     }
 
     // Find older versions of refs which we haven't processed yet. We're going
     // to make sure their commits are still reachable.
     $old_refs = id(new PhabricatorRepositoryOldRef())->loadAllWhere(
       'repositoryPHID = %s',
       $repository->getPHID());
 
     // If we don't have any refs to update, bail out before building a graph
     // stream. In particular, this improves behavior in empty repositories,
     // where `git log` exits with an error.
     if (!$old_refs) {
       return;
     }
 
     // We can share a single graph stream across all the checks we need to do.
     if ($repository->isGit()) {
       $stream = new PhabricatorGitGraphStream($repository);
     } else if ($repository->isHg()) {
       $stream = new PhabricatorMercurialGraphStream($repository);
     }
 
     foreach ($old_refs as $old_ref) {
       $identifier = $old_ref->getCommitIdentifier();
       $this->markUnreachableFrom($repository, $stream, $identifier);
 
       // If nothing threw an exception, we're all done with this ref.
       $old_ref->delete();
     }
   }
 
   private function markUnreachableFrom(
     PhabricatorRepository $repository,
     PhabricatorRepositoryGraphStream $stream,
     $identifier) {
 
     $unreachable = array();
 
     $commit = id(new PhabricatorRepositoryCommit())->loadOneWhere(
       'repositoryID = %s AND commitIdentifier = %s',
       $repository->getID(),
       $identifier);
     if (!$commit) {
       return;
     }
 
     $look = array($commit);
     $seen = array();
     while ($look) {
       $target = array_pop($look);
 
       // If we've already checked this commit (for example, because history
       // branches and then merges) we don't need to check it again.
       $target_identifier = $target->getCommitIdentifier();
       if (isset($seen[$target_identifier])) {
         continue;
       }
 
       $seen[$target_identifier] = true;
 
       // See PHI1688. If this commit is already marked as unreachable, we don't
       // need to consider its ancestors. This may skip a lot of work if many
       // branches with a lot of shared ancestry are deleted at the same time.
       if ($target->isUnreachable()) {
         continue;
       }
 
       try {
         $stream->getCommitDate($target_identifier);
         $reachable = true;
       } catch (Exception $ex) {
         $reachable = false;
       }
 
       if ($reachable) {
         // This commit is reachable, so we don't need to go any further
         // down this road.
         continue;
       }
 
       $unreachable[] = $target;
 
       // Find the commit's parents and check them for reachability, too. We
       // have to look in the database since we no may longer have the commit
       // in the repository.
       $rows = queryfx_all(
         $commit->establishConnection('w'),
         'SELECT commit.* FROM %T commit
           JOIN %T parents ON commit.id = parents.parentCommitID
           WHERE parents.childCommitID = %d',
         $commit->getTableName(),
         PhabricatorRepository::TABLE_PARENTS,
         $target->getID());
       if (!$rows) {
         continue;
       }
 
       $parents = id(new PhabricatorRepositoryCommit())
         ->loadAllFromArray($rows);
       foreach ($parents as $parent) {
         $look[] = $parent;
       }
     }
 
     $unreachable = array_reverse($unreachable);
 
     $flag = PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE;
     foreach ($unreachable as $unreachable_commit) {
       $unreachable_commit->writeImportStatusFlag($flag);
     }
 
     // If anything was unreachable, just rebuild the whole summary table.
     // We can't really update it incrementally when a commit becomes
     // unreachable.
     if ($unreachable) {
       $this->rebuildSummaryTable($repository);
     }
   }
 
   private function rebuildSummaryTable(PhabricatorRepository $repository) {
     $conn_w = $repository->establishConnection('w');
 
     $data = queryfx_one(
       $conn_w,
       'SELECT COUNT(*) N, MAX(id) id, MAX(epoch) epoch
         FROM %T WHERE repositoryID = %d AND (importStatus & %d) != %d',
       id(new PhabricatorRepositoryCommit())->getTableName(),
       $repository->getID(),
       PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE,
       PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE);
 
     queryfx(
       $conn_w,
       'INSERT INTO %T (repositoryID, size, lastCommitID, epoch)
         VALUES (%d, %d, %d, %d)
         ON DUPLICATE KEY UPDATE
           size = VALUES(size),
           lastCommitID = VALUES(lastCommitID),
           epoch = VALUES(epoch)',
       PhabricatorRepository::TABLE_SUMMARY,
       $repository->getID(),
       $data['N'],
       $data['id'],
       $data['epoch']);
   }
 
 }
diff --git a/src/applications/repository/engine/PhabricatorRepositoryPullEngine.php b/src/applications/repository/engine/PhabricatorRepositoryPullEngine.php
index f611c51112..af4cd95d0a 100644
--- a/src/applications/repository/engine/PhabricatorRepositoryPullEngine.php
+++ b/src/applications/repository/engine/PhabricatorRepositoryPullEngine.php
@@ -1,828 +1,828 @@
 <?php
 
 /**
  * Manages execution of `git pull` and `hg pull` commands for
  * @{class:PhabricatorRepository} objects. Used by
  * @{class:PhabricatorRepositoryPullLocalDaemon}.
  *
  * This class also covers initial working copy setup through `git clone`,
  * `git init`, `hg clone`, `hg init`, or `svnadmin create`.
  *
  * @task pull     Pulling Working Copies
  * @task git      Pulling Git Working Copies
  * @task hg       Pulling Mercurial Working Copies
  * @task svn      Pulling Subversion Working Copies
  * @task internal Internals
  */
 final class PhabricatorRepositoryPullEngine
   extends PhabricatorRepositoryEngine {
 
 
 /* -(  Pulling Working Copies  )--------------------------------------------- */
 
 
   public function pullRepository() {
     $repository = $this->getRepository();
 
     $lock = $this->newRepositoryLock($repository, 'repo.pull', true);
 
     try {
       $lock->lock();
     } catch (PhutilLockException $ex) {
       throw new DiffusionDaemonLockException(
         pht(
           'Another process is currently updating repository "%s", '.
           'skipping pull.',
           $repository->getDisplayName()));
     }
 
     try {
       $result = $this->pullRepositoryWithLock();
     } catch (Exception $ex) {
       $lock->unlock();
       throw $ex;
     }
 
     $lock->unlock();
 
     return $result;
   }
 
   private function pullRepositoryWithLock() {
     $repository = $this->getRepository();
     $viewer = PhabricatorUser::getOmnipotentUser();
 
     if ($repository->isReadOnly()) {
       $this->skipPull(
         pht(
           "Skipping pull on read-only repository.\n\n%s",
           $repository->getReadOnlyMessageForDisplay()));
     }
 
     $is_hg = false;
     $is_git = false;
     $is_svn = false;
 
     $vcs = $repository->getVersionControlSystem();
 
     switch ($vcs) {
       case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
         // We never pull a local copy of non-hosted Subversion repositories.
         if (!$repository->isHosted()) {
           $this->skipPull(
             pht(
               'Repository "%s" is a non-hosted Subversion repository, which '.
               'does not require a local working copy to be pulled.',
               $repository->getDisplayName()));
           return;
         }
         $is_svn = true;
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
         $is_git = true;
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
         $is_hg = true;
         break;
       default:
         $this->abortPull(pht('Unknown VCS "%s"!', $vcs));
         break;
     }
 
     $local_path = $repository->getLocalPath();
     if ($local_path === null) {
       $this->abortPull(
         pht(
           'No local path is configured for repository "%s".',
           $repository->getDisplayName()));
     }
 
     try {
       $dirname = dirname($local_path);
       if (!Filesystem::pathExists($dirname)) {
         Filesystem::createDirectory($dirname, 0755, $recursive = true);
       }
 
       if (!Filesystem::pathExists($local_path)) {
         $this->logPull(
           pht(
             'Creating a new working copy for repository "%s".',
             $repository->getDisplayName()));
         if ($is_git) {
           $this->executeGitCreate();
         } else if ($is_hg) {
           $this->executeMercurialCreate();
         } else {
           $this->executeSubversionCreate();
         }
       }
 
       id(new DiffusionRepositoryClusterEngine())
         ->setViewer($viewer)
         ->setRepository($repository)
         ->synchronizeWorkingCopyBeforeRead();
 
       if (!$repository->isHosted()) {
         $this->logPull(
           pht(
             'Updating the working copy for repository "%s".',
             $repository->getDisplayName()));
 
         if ($is_git) {
           $this->executeGitUpdate();
         } else if ($is_hg) {
           $this->executeMercurialUpdate();
         }
       }
 
       if ($repository->isHosted()) {
         if ($is_git) {
           $this->installGitHook();
         } else if ($is_svn) {
           $this->installSubversionHook();
         } else if ($is_hg) {
           $this->installMercurialHook();
         }
 
         foreach ($repository->getHookDirectories() as $directory) {
           $this->installHookDirectory($directory);
         }
       }
 
       if ($is_git) {
         $this->updateGitWorkingCopyConfiguration();
       }
 
     } catch (Exception $ex) {
       $this->abortPull(
         pht(
           "Pull of '%s' failed: %s",
           $repository->getDisplayName(),
           $ex->getMessage()),
         $ex);
     }
 
     $this->donePull();
 
     return $this;
   }
 
   private function skipPull($message) {
     $this->log($message);
     $this->donePull();
   }
 
   private function abortPull($message, Exception $ex = null) {
     $code_error = PhabricatorRepositoryStatusMessage::CODE_ERROR;
     $this->updateRepositoryInitStatus($code_error, $message);
     if ($ex) {
       throw $ex;
     } else {
       throw new Exception($message);
     }
   }
 
   private function logPull($message) {
     $this->log($message);
   }
 
   private function donePull() {
     $code_okay = PhabricatorRepositoryStatusMessage::CODE_OKAY;
     $this->updateRepositoryInitStatus($code_okay);
   }
 
   private function updateRepositoryInitStatus($code, $message = null) {
     $this->getRepository()->writeStatusMessage(
       PhabricatorRepositoryStatusMessage::TYPE_INIT,
       $code,
       array(
         'message' => $message,
       ));
   }
 
   private function installHook($path, array $hook_argv = array()) {
     $this->log(pht('Installing commit hook to "%s"...', $path));
 
     $repository = $this->getRepository();
     $identifier = $this->getHookContextIdentifier($repository);
 
     $root = dirname(phutil_get_library_root('phabricator'));
     $bin = $root.'/bin/commit-hook';
 
     $full_php_path = Filesystem::resolveBinary('php');
     $cmd = csprintf(
       'exec %s -f %s -- %s %Ls "$@"',
       $full_php_path,
       $bin,
       $identifier,
       $hook_argv);
 
     $hook = "#!/bin/sh\nexport TERM=dumb\n{$cmd}\n";
 
     Filesystem::writeFile($path, $hook);
     Filesystem::changePermissions($path, 0755);
   }
 
   private function installHookDirectory($path) {
     $readme = pht(
       "To add custom hook scripts to this repository, add them to this ".
       "directory.\n\nPhabricator will run any executables in this directory ".
       "after running its own checks, as though they were normal hook ".
       "scripts.");
 
     Filesystem::createDirectory($path, 0755);
     Filesystem::writeFile($path.'/README', $readme);
   }
 
   private function getHookContextIdentifier(PhabricatorRepository $repository) {
     $identifier = $repository->getPHID();
 
     $instance = PhabricatorEnv::getEnvConfig('cluster.instance');
     if (phutil_nonempty_string($instance)) {
       $identifier = "{$identifier}:{$instance}";
     }
 
     return $identifier;
   }
 
 
 /* -(  Pulling Git Working Copies  )----------------------------------------- */
 
 
   /**
    * @task git
    */
   private function executeGitCreate() {
     $repository = $this->getRepository();
 
     $path = rtrim($repository->getLocalPath(), '/');
 
     // See T13448. In all cases, we create repositories by using "git init"
     // to build a bare, empty working copy. If we try to use "git clone"
     // instead, we'll pull in too many refs if "Fetch Refs" is also
     // configured. There's no apparent way to make "git clone" behave narrowly
     // and no apparent reason to bother.
 
     $repository->execxRemoteCommand(
       'init --bare -- %s',
       $path);
   }
 
 
   /**
    * @task git
    */
   private function executeGitUpdate() {
     $repository = $this->getRepository();
 
     // See T13479. We previously used "--show-toplevel", but this stopped
     // working in Git 2.25.0 when run in a bare repository.
 
     // NOTE: As of Git 2.21.1, "git rev-parse" can not parse "--" in its
     // argument list, so we can not specify arguments unambiguously. Any
     // version of Git which does not recognize the "--git-dir" flag will
     // treat this as a request to parse the literal refname "--git-dir".
 
     list($err, $stdout) = $repository->execLocalCommand(
       'rev-parse --git-dir');
 
     $repository_root = null;
     $path = $repository->getLocalPath();
 
     if (!$err) {
       $repository_root = Filesystem::resolvePath(
         rtrim($stdout, "\n"),
         $path);
 
       // If we're in a bare Git repository, the "--git-dir" will be the
       // root directory. If we're in a working copy, the "--git-dir" will
       // be the ".git/" directory.
 
       // Test if the result is the root directory. If it is, we're in good
       // shape and appear to be inside a bare repository. If not, take the
       // parent directory to get out of the ".git/" folder.
 
       if (!Filesystem::pathsAreEquivalent($repository_root, $path)) {
         $repository_root = dirname($repository_root);
       }
     }
 
     $message = null;
     if ($err) {
       // Try to raise a more tailored error message in the more common case
       // of the user creating an empty directory. (We could try to remove it,
       // but might not be able to, and it's much simpler to raise a good
       // message than try to navigate those waters.)
       if (is_dir($path)) {
         $files = Filesystem::listDirectory($path, $include_hidden = true);
         if (!$files) {
           $message = pht(
             'Expected to find a Git repository at "%s", but there is an '.
             'empty directory there. Remove the directory. A daemon will '.
             'construct the working copy for you.',
             $path);
         } else {
           $message = pht(
             'Expected to find a Git repository at "%s", but there is '.
             'a non-repository directory (with other stuff in it) there. '.
             'Move or remove this directory. A daemon will construct '.
             'the working copy for you.',
             $path);
         }
       } else if (is_file($path)) {
         $message = pht(
           'Expected to find a Git repository at "%s", but there is a '.
           'file there instead. Move or remove this file. A daemon will '.
           'construct the working copy for you.',
           $path);
       } else {
         $message = pht(
           'Expected to find a git repository at "%s", but did not.',
           $path);
       }
     } else {
 
       // Prior to Git 2.25.0, we used "--show-toplevel", which had a weird
       // case here when the working copy was inside another working copy.
       // The switch to "--git-dir" seems to have resolved this; we now seem
       // to find the nearest git directory and thus the correct repository
       // root.
 
       if (!Filesystem::pathsAreEquivalent($repository_root, $path)) {
         $err = true;
         $message = pht(
           'Expected to find a Git repository at "%s", but the actual Git '.
           'repository root for this directory is "%s". Something is '.
           'misconfigured. This directory should be writable by the daemons '.
           'and not inside another Git repository.',
           $path,
           $repository_root);
       }
     }
 
     if ($err && $repository->canDestroyWorkingCopy()) {
       phlog(
         pht(
           "Repository working copy at '%s' failed sanity check; ".
           "destroying and re-cloning. %s",
           $path,
           $message));
       Filesystem::remove($path);
       $this->executeGitCreate();
     } else if ($err) {
       throw new Exception($message);
     }
 
     // Load the refs we're planning to fetch from the remote repository.
     $remote_refs = $this->loadGitRemoteRefs(
       $repository,
       $repository->getRemoteURIEnvelope(),
       $is_local = false);
 
     // Load the refs we're planning to fetch from the local repository, by
     // using the local working copy path as the "remote" repository URI.
     $local_refs = $this->loadGitRemoteRefs(
       $repository,
       new PhutilOpaqueEnvelope($path),
       $is_local = true);
 
     // See T13448. The "git fetch --prune ..." flag only prunes local refs
     // matching the refspecs we pass it. If "Fetch Refs" is configured, we'll
     // pass it a very narrow list of refspecs, and it won't prune older refs
     // that aren't currently subject to fetching.
 
     // Since we want to prune everything that isn't (a) on the fetch list and
     // (b) in the remote, handle pruning of any surplus leftover refs ourselves
     // before we fetch anything.
 
     // (We don't have to do this if "Fetch Refs" isn't set up, since "--prune"
     // will work in that case, but it's a little simpler to always go down the
     // same code path.)
 
     $surplus_refs = array();
     foreach ($local_refs as $local_ref => $local_hash) {
       $remote_hash = idx($remote_refs, $local_ref);
       if ($remote_hash === null) {
         $surplus_refs[] = $local_ref;
       }
     }
 
     if ($surplus_refs) {
       $this->log(
         pht(
           'Found %s surplus local ref(s) to delete.',
           phutil_count($surplus_refs)));
       foreach ($surplus_refs as $surplus_ref) {
         $this->log(
           pht(
             'Deleting surplus local ref "%s" ("%s").',
             $surplus_ref,
             $local_refs[$surplus_ref]));
 
         $repository->execLocalCommand(
           'update-ref -d %R --',
           $surplus_ref);
 
         unset($local_refs[$surplus_ref]);
       }
     }
 
     if ($remote_refs === $local_refs) {
       $this->log(
         pht(
           'Skipping fetch because local and remote refs are already '.
           'identical.'));
       return false;
     }
 
     $this->logRefDifferences($remote_refs, $local_refs);
 
     $fetch_rules = $this->getGitFetchRules($repository);
 
     // For very old non-bare working copies, we need to use "--update-head-ok"
     // to tell Git that it is allowed to overwrite whatever is currently
     // checked out. See T13280.
 
     $future = $repository->getRemoteCommandFuture(
       'fetch --no-tags --update-head-ok -- %P %Ls',
       $repository->getRemoteURIEnvelope(),
       $fetch_rules);
 
     $future
       ->setCWD($path)
       ->resolvex();
   }
 
   private function getGitRefRules(PhabricatorRepository $repository) {
     $ref_rules = $repository->getFetchRules($repository);
 
     if (!$ref_rules) {
       $ref_rules = array(
         'refs/*',
       );
     }
 
     return $ref_rules;
   }
 
   private function getGitFetchRules(PhabricatorRepository $repository) {
     $ref_rules = $this->getGitRefRules($repository);
 
     // Rewrite each ref rule "X" into "+X:X".
 
     // The "X" means "fetch ref X".
     // The "...:X" means "...and copy it into local ref X".
     // The "+..." means "...and overwrite the local ref if it already exists".
 
     $fetch_rules = array();
     foreach ($ref_rules as $key => $ref_rule) {
       $fetch_rules[] = sprintf(
         '+%s:%s',
         $ref_rule,
         $ref_rule);
     }
 
     return $fetch_rules;
   }
 
   /**
    * @task git
    */
   private function installGitHook() {
     $repository = $this->getRepository();
     $root = $repository->getLocalPath();
 
     if ($repository->isWorkingCopyBare()) {
       $path = '/hooks/pre-receive';
     } else {
       $path = '/.git/hooks/pre-receive';
     }
 
     $this->installHook($root.$path);
   }
 
   private function updateGitWorkingCopyConfiguration() {
     $repository = $this->getRepository();
 
     // See T5963. When you "git clone" from a remote with no "master", the
     // client warns you that it isn't sure what it should check out as an
     // initial state:
 
     //   warning: remote HEAD refers to nonexistent ref, unable to checkout
 
     // We can tell the client what it should check out by making "HEAD"
     // point somewhere. However:
     //
     // (1) If we don't set "receive.denyDeleteCurrent" to "ignore" and a user
     // tries to delete the default branch, Git raises an error and refuses.
     // We want to allow this; we already have sufficient protections around
     // dangerous changes and do not need to special case the default branch.
     //
     // (2) A repository may have a nonexistent default branch configured.
     // For now, we just respect configuration. This will raise a warning when
     // users clone the repository.
     //
     // In any case, these changes are both advisory, so ignore any errors we
     // may encounter.
 
     // We do this for both hosted and observed repositories. Although it is
     // not terribly common to clone from Phabricator's copy of an observed
     // repository, it works fine and makes sense occasionally.
 
     if ($repository->isWorkingCopyBare()) {
       $repository->execLocalCommand(
         'config -- receive.denyDeleteCurrent ignore');
       $repository->execLocalCommand(
         'symbolic-ref HEAD %s',
         'refs/heads/'.$repository->getDefaultBranch());
     }
   }
 
   private function loadGitRemoteRefs(
     PhabricatorRepository $repository,
     PhutilOpaqueEnvelope $remote_envelope,
     $is_local) {
 
     // See T13448. When listing local remotes, we want to list everything,
     // not just refs we expect to fetch. This allows us to detect that we have
     // undesirable refs (which have been deleted in the remote, but are still
     // present locally) so we can update our state to reflect the correct
     // remote state.
 
     if ($is_local) {
       $ref_rules = array();
     } else {
       $ref_rules = $this->getGitRefRules($repository);
 
       // NOTE: "git ls-remote" does not support "--" until circa January 2016.
       // See T12416. None of the flags to "ls-remote" appear dangerous, but
       // refuse to list any refs beginning with "-" just in case.
 
       foreach ($ref_rules as $ref_rule) {
         if (preg_match('/^-/', $ref_rule)) {
           throw new Exception(
             pht(
               'Refusing to list potentially dangerous ref ("%s") beginning '.
               'with "-".',
               $ref_rule));
         }
       }
     }
 
     list($stdout) = $repository->execxRemoteCommand(
       'ls-remote %P %Ls',
       $remote_envelope,
       $ref_rules);
 
     // Empty repositories don't have any refs.
     if (!strlen(rtrim($stdout))) {
       return array();
     }
 
     $map = array();
     $lines = phutil_split_lines($stdout, false);
     foreach ($lines as $line) {
       list($hash, $name) = preg_split('/\s+/', $line, 2);
 
       // If the remote has a HEAD, just ignore it.
       if ($name == 'HEAD') {
         continue;
       }
 
       // If the remote ref is itself a remote ref, ignore it.
       if (preg_match('(^refs/remotes/)', $name)) {
         continue;
       }
 
       $map[$name] = $hash;
     }
 
     ksort($map);
 
     return $map;
   }
 
   private function logRefDifferences(array $remote, array $local) {
     $all = $local + $remote;
 
     $differences = array();
     foreach ($all as $key => $ignored) {
       $remote_ref = idx($remote, $key, pht('<null>'));
       $local_ref = idx($local, $key, pht('<null>'));
       if ($remote_ref !== $local_ref) {
         $differences[] = pht(
           '%s (remote: "%s", local: "%s")',
           $key,
           $remote_ref,
           $local_ref);
       }
     }
 
     $this->log(
       pht(
         "Updating repository after detecting ref differences:\n%s",
         implode("\n", $differences)));
   }
 
 
 
 /* -(  Pulling Mercurial Working Copies  )----------------------------------- */
 
 
   /**
    * @task hg
    */
   private function executeMercurialCreate() {
     $repository = $this->getRepository();
 
     $path = rtrim($repository->getLocalPath(), '/');
 
     if ($repository->isHosted()) {
       $repository->execxRemoteCommand(
         'init -- %s',
         $path);
     } else {
       $remote = $repository->getRemoteURIEnvelope();
 
       // NOTE: Mercurial prior to 3.2.4 has an severe command injection
       // vulnerability. See: <http://bit.ly/19B58E9>
 
       // On vulnerable versions of Mercurial, we refuse to clone remotes which
       // contain characters which may be interpreted by the shell.
       $hg_binary = PhutilBinaryAnalyzer::getForBinary('hg');
       $is_vulnerable = $hg_binary->isMercurialVulnerableToInjection();
       if ($is_vulnerable) {
         $cleartext = $remote->openEnvelope();
         // The use of "%R" here is an attempt to limit collateral damage
         // for normal URIs because it isn't clear how long this vulnerability
         // has been around for.
 
         $escaped = csprintf('%R', $cleartext);
         if ((string)$escaped !== (string)$cleartext) {
           throw new Exception(
             pht(
               'You have an old version of Mercurial (%s) which has a severe '.
               'command injection security vulnerability. The remote URI for '.
               'this repository (%s) is potentially unsafe. Upgrade Mercurial '.
               'to at least 3.2.4 to clone it.',
               $hg_binary->getBinaryVersion(),
               $repository->getMonogram()));
         }
       }
 
       try {
         $repository->execxRemoteCommand(
           'clone --noupdate -- %P %s',
           $remote,
           $path);
       } catch (Exception $ex) {
         $message = $ex->getMessage();
         $message = $this->censorMercurialErrorMessage($message);
         throw new Exception($message);
       }
     }
   }
 
 
   /**
    * @task hg
    */
   private function executeMercurialUpdate() {
     $repository = $this->getRepository();
     $path = $repository->getLocalPath();
 
     // This is a local command, but needs credentials.
     $remote = $repository->getRemoteURIEnvelope();
     $future = $repository->getRemoteCommandFuture('pull -- %P', $remote);
     $future->setCWD($path);
 
     try {
       $future->resolvex();
     } catch (CommandException $ex) {
       $err = $ex->getError();
       $stdout = $ex->getStdout();
 
       // NOTE: Between versions 2.1 and 2.1.1, Mercurial changed the behavior
       // of "hg pull" to return 1 in case of a successful pull with no changes.
       // This behavior has been reverted, but users who updated between Feb 1,
       // 2012 and Mar 1, 2012 will have the erroring version. Do a dumb test
       // against stdout to check for this possibility.
 
       // NOTE: Mercurial has translated versions, which translate this error
       // string. In a translated version, the string will be something else,
       // like "aucun changement trouve". There didn't seem to be an easy way
       // to handle this (there are hard ways but this is not a common problem
       // and only creates log spam, not application failures). Assume English.
 
       // TODO: Remove this once we're far enough in the future that deployment
       // of 2.1 is exceedingly rare?
       if ($err == 1 && preg_match('/no changes found/', $stdout)) {
         return;
       } else {
         $message = $ex->getMessage();
         $message = $this->censorMercurialErrorMessage($message);
         throw new Exception($message);
       }
     }
   }
 
 
   /**
    * Censor response bodies from Mercurial error messages.
    *
    * When Mercurial attempts to clone an HTTP repository but does not
    * receive a response it expects, it emits the response body in the
    * command output.
    *
    * This represents a potential SSRF issue, because an attacker with
    * permission to create repositories can create one which points at the
    * remote URI for some local service, then read the response from the
    * error message. To prevent this, censor response bodies out of error
    * messages.
    *
-   * @param string Uncensored Mercurial command output.
+   * @param string $message Uncensored Mercurial command output.
    * @return string Censored Mercurial command output.
    */
   private function censorMercurialErrorMessage($message) {
     return preg_replace(
       '/^---%<---.*/sm',
       pht('<Response body omitted from Mercurial error message.>')."\n",
       $message);
   }
 
 
   /**
    * @task hg
    */
   private function installMercurialHook() {
     $repository = $this->getRepository();
     $path = $repository->getLocalPath().'/.hg/hgrc';
 
     $identifier = $this->getHookContextIdentifier($repository);
 
     $root = dirname(phutil_get_library_root('phabricator'));
     $bin = $root.'/bin/commit-hook';
 
     $data = array();
     $data[] = '[hooks]';
 
     // This hook handles normal pushes.
     $data[] = csprintf(
       'pretxnchangegroup.phabricator = TERM=dumb %s %s %s',
       $bin,
       $identifier,
       'pretxnchangegroup');
 
     // This one handles creating bookmarks.
     $data[] = csprintf(
       'prepushkey.phabricator = TERM=dumb %s %s %s',
       $bin,
       $identifier,
       'prepushkey');
 
     $data[] = null;
 
     $data = implode("\n", $data);
 
     $this->log('%s', pht('Installing commit hook config to "%s"...', $path));
 
     Filesystem::writeFile($path, $data);
   }
 
 
 /* -(  Pulling Subversion Working Copies  )---------------------------------- */
 
 
   /**
    * @task svn
    */
   private function executeSubversionCreate() {
     $repository = $this->getRepository();
 
     $path = rtrim($repository->getLocalPath(), '/');
     execx('svnadmin create -- %s', $path);
   }
 
 
   /**
    * @task svn
    */
   private function installSubversionHook() {
     $repository = $this->getRepository();
     $root = $repository->getLocalPath();
 
     $path = '/hooks/pre-commit';
     $this->installHook($root.$path);
 
     $revprop_path = '/hooks/pre-revprop-change';
 
     $revprop_argv = array(
       '--hook-mode',
       'svn-revprop',
     );
 
     $this->installHook($root.$revprop_path, $revprop_argv);
   }
 
 
 }
diff --git a/src/applications/repository/engine/PhabricatorRepositoryRefEngine.php b/src/applications/repository/engine/PhabricatorRepositoryRefEngine.php
index 60a96578a3..6cb9f27384 100644
--- a/src/applications/repository/engine/PhabricatorRepositoryRefEngine.php
+++ b/src/applications/repository/engine/PhabricatorRepositoryRefEngine.php
@@ -1,730 +1,730 @@
 <?php
 
 /**
  * Update the ref cursors for a repository, which track the positions of
  * branches, bookmarks, and tags.
  */
 final class PhabricatorRepositoryRefEngine
   extends PhabricatorRepositoryEngine {
 
   private $newPositions = array();
   private $deadPositions = array();
   private $permanentCommits = array();
   private $rebuild;
 
   public function setRebuild($rebuild) {
     $this->rebuild = $rebuild;
     return $this;
   }
 
   public function getRebuild() {
     return $this->rebuild;
   }
 
   public function updateRefs() {
     $this->newPositions = array();
     $this->deadPositions = array();
     $this->permanentCommits = array();
 
     $repository = $this->getRepository();
     $viewer = $this->getViewer();
 
     $branches_may_close = false;
 
     $vcs = $repository->getVersionControlSystem();
     switch ($vcs) {
       case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
         // No meaningful refs of any type in Subversion.
         $maps = array();
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
         $branches = $this->loadMercurialBranchPositions($repository);
         $bookmarks = $this->loadMercurialBookmarkPositions($repository);
         $maps = array(
           PhabricatorRepositoryRefCursor::TYPE_BRANCH => $branches,
           PhabricatorRepositoryRefCursor::TYPE_BOOKMARK => $bookmarks,
         );
 
         $branches_may_close = true;
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
         $maps = $this->loadGitRefPositions($repository);
         break;
       default:
         throw new Exception(pht('Unknown VCS "%s"!', $vcs));
     }
 
     // Fill in any missing types with empty lists.
     $maps = $maps + array(
       PhabricatorRepositoryRefCursor::TYPE_BRANCH => array(),
       PhabricatorRepositoryRefCursor::TYPE_TAG => array(),
       PhabricatorRepositoryRefCursor::TYPE_BOOKMARK => array(),
       PhabricatorRepositoryRefCursor::TYPE_REF => array(),
     );
 
     $all_cursors = id(new PhabricatorRepositoryRefCursorQuery())
       ->setViewer($viewer)
       ->withRepositoryPHIDs(array($repository->getPHID()))
       ->needPositions(true)
       ->execute();
     $cursor_groups = mgroup($all_cursors, 'getRefType');
 
     // Find all the heads of permanent refs.
     $all_closing_heads = array();
     foreach ($all_cursors as $cursor) {
 
       // See T13284. Note that we're considering whether this ref was a
       // permanent ref or not the last time we updated refs for this
       // repository. This allows us to handle things properly when a ref
       // is reconfigured from non-permanent to permanent.
 
       $was_permanent = $cursor->getIsPermanent();
       if (!$was_permanent) {
         continue;
       }
 
       foreach ($cursor->getPositionIdentifiers() as $identifier) {
         $all_closing_heads[] = $identifier;
       }
     }
 
     $all_closing_heads = array_unique($all_closing_heads);
     $all_closing_heads = $this->removeMissingCommits($all_closing_heads);
 
     foreach ($maps as $type => $refs) {
       $cursor_group = idx($cursor_groups, $type, array());
       $this->updateCursors($cursor_group, $refs, $type, $all_closing_heads);
     }
 
     if ($this->permanentCommits) {
       $this->setPermanentFlagOnCommits($this->permanentCommits);
     }
 
     $save_cursors = $this->getCursorsForUpdate($repository, $all_cursors);
 
     if ($this->newPositions || $this->deadPositions || $save_cursors) {
       $repository->openTransaction();
 
         $this->saveNewPositions();
         $this->deleteDeadPositions();
 
         foreach ($save_cursors as $cursor) {
           $cursor->save();
         }
 
       $repository->saveTransaction();
     }
 
     $branches = $maps[PhabricatorRepositoryRefCursor::TYPE_BRANCH];
     if ($branches && $branches_may_close) {
       $this->updateBranchStates($repository, $branches);
     }
   }
 
   private function getCursorsForUpdate(
     PhabricatorRepository $repository,
     array $cursors) {
     assert_instances_of($cursors, 'PhabricatorRepositoryRefCursor');
 
     $publisher = $repository->newPublisher();
 
     $results = array();
 
     foreach ($cursors as $cursor) {
       $diffusion_ref = $cursor->newDiffusionRepositoryRef();
 
       $is_permanent = $publisher->isPermanentRef($diffusion_ref);
       if ($is_permanent == $cursor->getIsPermanent()) {
         continue;
       }
 
       $cursor->setIsPermanent((int)$is_permanent);
       $results[] = $cursor;
     }
 
     return $results;
   }
 
   private function updateBranchStates(
     PhabricatorRepository $repository,
     array $branches) {
 
     assert_instances_of($branches, 'DiffusionRepositoryRef');
     $viewer = $this->getViewer();
 
     $all_cursors = id(new PhabricatorRepositoryRefCursorQuery())
       ->setViewer($viewer)
       ->withRepositoryPHIDs(array($repository->getPHID()))
       ->needPositions(true)
       ->execute();
 
     $state_map = array();
     $type_branch = PhabricatorRepositoryRefCursor::TYPE_BRANCH;
     foreach ($all_cursors as $cursor) {
       if ($cursor->getRefType() !== $type_branch) {
         continue;
       }
       $raw_name = $cursor->getRefNameRaw();
 
       foreach ($cursor->getPositions() as $position) {
         $hash = $position->getCommitIdentifier();
         $state_map[$raw_name][$hash] = $position;
       }
     }
 
     $updates = array();
     foreach ($branches as $branch) {
       $position = idx($state_map, $branch->getShortName(), array());
       $position = idx($position, $branch->getCommitIdentifier());
       if (!$position) {
         continue;
       }
 
       $fields = $branch->getRawFields();
 
       $position_state = (bool)$position->getIsClosed();
       $branch_state = (bool)idx($fields, 'closed');
 
       if ($position_state != $branch_state) {
         $updates[$position->getID()] = (int)$branch_state;
       }
     }
 
     if ($updates) {
       $position_table = id(new PhabricatorRepositoryRefPosition());
       $conn = $position_table->establishConnection('w');
 
       $position_table->openTransaction();
         foreach ($updates as $position_id => $branch_state) {
           queryfx(
             $conn,
             'UPDATE %T SET isClosed = %d WHERE id = %d',
             $position_table->getTableName(),
             $branch_state,
             $position_id);
         }
       $position_table->saveTransaction();
     }
   }
 
   private function markPositionNew(
     PhabricatorRepositoryRefPosition $position) {
     $this->newPositions[] = $position;
     return $this;
   }
 
   private function markPositionDead(
     PhabricatorRepositoryRefPosition $position) {
     $this->deadPositions[] = $position;
     return $this;
   }
 
   private function markPermanentCommits(array $identifiers) {
     foreach ($identifiers as $identifier) {
       $this->permanentCommits[$identifier] = $identifier;
     }
     return $this;
   }
 
   /**
    * Remove commits which no longer exist in the repository from a list.
    *
    * After a force push and garbage collection, we may have branch cursors which
    * point at commits which no longer exist. This can make commands issued later
    * fail. See T5839 for discussion.
    *
-   * @param list<string>    List of commit identifiers.
+   * @param list<string>    $identifiers List of commit identifiers.
    * @return list<string>   List with nonexistent identifiers removed.
    */
   private function removeMissingCommits(array $identifiers) {
     if (!$identifiers) {
       return array();
     }
 
     $resolved = id(new DiffusionLowLevelResolveRefsQuery())
       ->setRepository($this->getRepository())
       ->withRefs($identifiers)
       ->execute();
 
     foreach ($identifiers as $key => $identifier) {
       if (empty($resolved[$identifier])) {
         unset($identifiers[$key]);
       }
     }
 
     return $identifiers;
   }
 
   private function updateCursors(
     array $cursors,
     array $new_refs,
     $ref_type,
     array $all_closing_heads) {
     $repository = $this->getRepository();
     $publisher = $repository->newPublisher();
 
     // NOTE: Mercurial branches may have multiple branch heads; this logic
     // is complex primarily to account for that.
 
     $cursors = mpull($cursors, null, 'getRefNameRaw');
 
     // Group all the new ref values by their name. As above, these groups may
     // have multiple members in Mercurial.
     $ref_groups = mgroup($new_refs, 'getShortName');
 
     foreach ($ref_groups as $name => $refs) {
       $new_commits = mpull($refs, 'getCommitIdentifier', 'getCommitIdentifier');
 
       $ref_cursor = idx($cursors, $name);
       if ($ref_cursor) {
         $old_positions = $ref_cursor->getPositions();
       } else {
         $old_positions = array();
       }
 
       // We're going to delete all the cursors pointing at commits which are
       // no longer associated with the refs. This primarily makes the Mercurial
       // multiple head case easier, and means that when we update a ref we
       // delete the old one and write a new one.
       foreach ($old_positions as $old_position) {
         $hash = $old_position->getCommitIdentifier();
         if (isset($new_commits[$hash])) {
           // This ref previously pointed at this commit, and still does.
           $this->log(
             pht(
               'Ref %s "%s" still points at %s.',
               $ref_type,
               $name,
               $hash));
           continue;
         }
 
         // This ref previously pointed at this commit, but no longer does.
         $this->log(
           pht(
             'Ref %s "%s" no longer points at %s.',
             $ref_type,
             $name,
             $hash));
 
         // Nuke the obsolete cursor.
         $this->markPositionDead($old_position);
       }
 
       // Now, we're going to insert new cursors for all the commits which are
       // associated with this ref that don't currently have cursors.
       $old_commits = mpull($old_positions, 'getCommitIdentifier');
       $old_commits = array_fuse($old_commits);
 
       $added_commits = array_diff_key($new_commits, $old_commits);
       foreach ($added_commits as $identifier) {
         $this->log(
           pht(
             'Ref %s "%s" now points at %s.',
             $ref_type,
             $name,
             $identifier));
 
         if (!$ref_cursor) {
           // If this is the first time we've seen a particular ref (for
           // example, a new branch) we need to insert a RefCursor record
           // for it before we can insert a RefPosition.
 
           $ref_cursor = $this->newRefCursor(
             $repository,
             $ref_type,
             $name);
         }
 
         $new_position = id(new PhabricatorRepositoryRefPosition())
           ->setCursorID($ref_cursor->getID())
           ->setCommitIdentifier($identifier)
           ->setIsClosed(0);
 
         $this->markPositionNew($new_position);
       }
 
       if ($publisher->isPermanentRef(head($refs))) {
 
         // See T13284. If this cursor was already marked as permanent, we
         // only need to publish the newly created ref positions. However, if
         // this cursor was not previously permanent but has become permanent,
         // we need to publish all the ref positions.
 
         // This corresponds to users reconfiguring a branch to make it
         // permanent without pushing any new commits to it.
 
         $is_rebuild = $this->getRebuild();
         $was_permanent = $ref_cursor->getIsPermanent();
 
         if ($is_rebuild || !$was_permanent) {
           $update_all = true;
         } else {
           $update_all = false;
         }
 
         if ($update_all) {
           $update_commits = $new_commits;
         } else {
           $update_commits = $added_commits;
         }
 
         if ($is_rebuild) {
           $exclude = array();
         } else {
           $exclude = $all_closing_heads;
         }
 
         foreach ($update_commits as $identifier) {
           $new_identifiers = $this->loadNewCommitIdentifiers(
             $identifier,
             $exclude);
 
           $this->markPermanentCommits($new_identifiers);
         }
       }
     }
 
     // Find any cursors for refs which no longer exist. This happens when a
     // branch, tag or bookmark is deleted.
 
     foreach ($cursors as $name => $cursor) {
       if (!empty($ref_groups[$name])) {
         // This ref still has some positions, so we don't need to wipe it
         // out. Try the next one.
         continue;
       }
 
       foreach ($cursor->getPositions() as $position) {
         $this->log(
           pht(
             'Ref %s "%s" no longer exists.',
             $cursor->getRefType(),
             $cursor->getRefName()));
 
         $this->markPositionDead($position);
       }
     }
   }
 
   /**
    * Find all ancestors of a new closing branch head which are not ancestors
    * of any old closing branch head.
    */
   private function loadNewCommitIdentifiers(
     $new_head,
     array $all_closing_heads) {
 
     $repository = $this->getRepository();
     $vcs = $repository->getVersionControlSystem();
     switch ($vcs) {
       case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
         if ($all_closing_heads) {
           $parts = array();
           foreach ($all_closing_heads as $head) {
             $parts[] = hgsprintf('%s', $head);
           }
 
           // See T5896. Mercurial can not parse an "X or Y or ..." rev list
           // with more than about 300 items, because it exceeds the maximum
           // allowed recursion depth. Split all the heads into chunks of
           // 256, and build a query like this:
           //
           //   ((1 or 2 or ... or 255) or (256 or 257 or ... 511))
           //
           // If we have more than 65535 heads, we'll do that again:
           //
           //   (((1 or ...) or ...) or ((65536 or ...) or ...))
 
           $chunk_size = 256;
           while (count($parts) > $chunk_size) {
             $chunks = array_chunk($parts, $chunk_size);
             foreach ($chunks as $key => $chunk) {
               $chunks[$key] = '('.implode(' or ', $chunk).')';
             }
             $parts = array_values($chunks);
           }
           $parts = '('.implode(' or ', $parts).')';
 
           list($stdout) = $this->getRepository()->execxLocalCommand(
             'log --template %s --rev %s',
             '{node}\n',
             hgsprintf('%s', $new_head).' - '.$parts);
         } else {
           list($stdout) = $this->getRepository()->execxLocalCommand(
             'log --template %s --rev %s',
             '{node}\n',
             hgsprintf('%s', $new_head));
         }
 
         $stdout = trim($stdout);
         if (!strlen($stdout)) {
           return array();
         }
         return phutil_split_lines($stdout, $retain_newlines = false);
       case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
         if ($all_closing_heads) {
 
           // See PHI1474. This length of list may exceed the maximum size of
           // a command line argument list, so pipe the list in using "--stdin"
           // instead.
 
           $ref_list = array();
           $ref_list[] = $new_head;
           foreach ($all_closing_heads as $old_head) {
             $ref_list[] = '^'.$old_head;
           }
           $ref_list[] = '--';
           $ref_list = implode("\n", $ref_list)."\n";
 
           $future = $this->getRepository()->getLocalCommandFuture(
             'log %s --stdin --',
             '--format=%H');
 
           list($stdout) = $future
             ->write($ref_list)
             ->resolvex();
         } else {
           list($stdout) = $this->getRepository()->execxLocalCommand(
             'log %s %s --',
             '--format=%H',
             gitsprintf('%s', $new_head));
         }
 
         $stdout = trim($stdout);
         if (!strlen($stdout)) {
           return array();
         }
         return phutil_split_lines($stdout, $retain_newlines = false);
       default:
         throw new Exception(pht('Unsupported VCS "%s"!', $vcs));
     }
   }
 
   /**
    * Mark a list of commits as permanent, and queue workers for those commits
    * which don't already have the flag.
    */
   private function setPermanentFlagOnCommits(array $identifiers) {
     $repository = $this->getRepository();
     $commit_table = new PhabricatorRepositoryCommit();
     $conn = $commit_table->establishConnection('w');
 
     $vcs = $repository->getVersionControlSystem();
     switch ($vcs) {
       case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
         $class = 'PhabricatorRepositoryGitCommitMessageParserWorker';
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
         $class = 'PhabricatorRepositorySvnCommitMessageParserWorker';
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
         $class = 'PhabricatorRepositoryMercurialCommitMessageParserWorker';
         break;
       default:
         throw new Exception(pht("Unknown repository type '%s'!", $vcs));
     }
 
     $identifier_tokens = array();
     foreach ($identifiers as $identifier) {
       $identifier_tokens[] = qsprintf(
         $conn,
         '%s',
         $identifier);
     }
 
     $all_commits = array();
     foreach (PhabricatorLiskDAO::chunkSQL($identifier_tokens) as $chunk) {
       $rows = queryfx_all(
         $conn,
         'SELECT id, phid, commitIdentifier, importStatus FROM %T
           WHERE repositoryID = %d AND commitIdentifier IN (%LQ)',
         $commit_table->getTableName(),
         $repository->getID(),
         $chunk);
       foreach ($rows as $row) {
         $all_commits[] = $row;
       }
     }
 
     $commit_refs = array();
     foreach ($identifiers as $identifier) {
 
       // See T13591. This construction is a bit ad-hoc, but the priority
       // function currently only cares about the number of refs we have
       // discovered, so we'll get the right result even without filling
       // these records out in detail.
 
       $commit_refs[] = id(new PhabricatorRepositoryCommitRef())
         ->setIdentifier($identifier);
     }
 
     $task_priority = $this->getImportTaskPriority(
       $repository,
       $commit_refs);
 
     $permanent_flag = PhabricatorRepositoryCommit::IMPORTED_PERMANENT;
     $published_flag = PhabricatorRepositoryCommit::IMPORTED_PUBLISH;
 
     $all_commits = ipull($all_commits, null, 'commitIdentifier');
     foreach ($identifiers as $identifier) {
       $row = idx($all_commits, $identifier);
 
       if (!$row) {
         throw new Exception(
           pht(
             'Commit "%s" has not been discovered yet! Run discovery before '.
             'updating refs.',
             $identifier));
       }
 
       $import_status = $row['importStatus'];
       if (!($import_status & $permanent_flag)) {
         // Set the "permanent" flag.
         $import_status = ($import_status | $permanent_flag);
 
         // See T13580. Clear the "published" flag, so publishing executes
         // again. We may have previously performed a no-op "publish" on the
         // commit to make sure it has all bits in the "IMPORTED_ALL" bitmask.
         $import_status = ($import_status & ~$published_flag);
 
         queryfx(
           $conn,
           'UPDATE %T SET importStatus = %d WHERE id = %d',
           $commit_table->getTableName(),
           $import_status,
           $row['id']);
 
         $this->queueCommitImportTask(
           $repository,
           $row['phid'],
           $task_priority,
           $via = 'ref');
       }
     }
 
     return $this;
   }
 
   private function newRefCursor(
     PhabricatorRepository $repository,
     $ref_type,
     $ref_name) {
 
     $cursor = id(new PhabricatorRepositoryRefCursor())
       ->setRepositoryPHID($repository->getPHID())
       ->setRefType($ref_type)
       ->setRefName($ref_name);
 
     $publisher = $repository->newPublisher();
 
     $diffusion_ref = $cursor->newDiffusionRepositoryRef();
     $is_permanent = $publisher->isPermanentRef($diffusion_ref);
 
     $cursor->setIsPermanent((int)$is_permanent);
 
     try {
       return $cursor->save();
     } catch (AphrontDuplicateKeyQueryException $ex) {
       // If we raced another daemon to create this position and lost the race,
       // load the cursor the other daemon created instead.
     }
 
     $viewer = $this->getViewer();
 
     $cursor = id(new PhabricatorRepositoryRefCursorQuery())
       ->setViewer($viewer)
       ->withRepositoryPHIDs(array($repository->getPHID()))
       ->withRefTypes(array($ref_type))
       ->withRefNames(array($ref_name))
       ->needPositions(true)
       ->executeOne();
     if (!$cursor) {
       throw new Exception(
         pht(
           'Failed to create a new ref cursor (for "%s", of type "%s", in '.
           'repository "%s") because it collided with an existing cursor, '.
           'but then failed to load that cursor.',
           $ref_name,
           $ref_type,
           $repository->getDisplayName()));
     }
 
     return $cursor;
   }
 
   private function saveNewPositions() {
     $positions = $this->newPositions;
 
     foreach ($positions as $position) {
       try {
         $position->save();
       } catch (AphrontDuplicateKeyQueryException $ex) {
         // We may race another daemon to create this position. If we do, and
         // we lose the race, that's fine: the other daemon did our work for
         // us and we can continue.
       }
     }
 
     $this->newPositions = array();
   }
 
   private function deleteDeadPositions() {
     $positions = $this->deadPositions;
     $repository = $this->getRepository();
 
     foreach ($positions as $position) {
       // Shove this ref into the old refs table so the discovery engine
       // can check if any commits have been rendered unreachable.
       id(new PhabricatorRepositoryOldRef())
         ->setRepositoryPHID($repository->getPHID())
         ->setCommitIdentifier($position->getCommitIdentifier())
         ->save();
 
       $position->delete();
     }
 
     $this->deadPositions = array();
   }
 
 
 
 /* -(  Updating Git Refs  )-------------------------------------------------- */
 
 
   /**
    * @task git
    */
   private function loadGitRefPositions(PhabricatorRepository $repository) {
     $refs = id(new DiffusionLowLevelGitRefQuery())
       ->setRepository($repository)
       ->execute();
 
     return mgroup($refs, 'getRefType');
   }
 
 
 /* -(  Updating Mercurial Refs  )-------------------------------------------- */
 
 
   /**
    * @task hg
    */
   private function loadMercurialBranchPositions(
     PhabricatorRepository $repository) {
     return id(new DiffusionLowLevelMercurialBranchesQuery())
       ->setRepository($repository)
       ->execute();
   }
 
 
   /**
    * @task hg
    */
   private function loadMercurialBookmarkPositions(
     PhabricatorRepository $repository) {
     // TODO: Implement support for Mercurial bookmarks.
     return array();
   }
 
 }
diff --git a/src/applications/repository/graphcache/PhabricatorRepositoryGraphCache.php b/src/applications/repository/graphcache/PhabricatorRepositoryGraphCache.php
index c4adc61ab0..2d9c36fae7 100644
--- a/src/applications/repository/graphcache/PhabricatorRepositoryGraphCache.php
+++ b/src/applications/repository/graphcache/PhabricatorRepositoryGraphCache.php
@@ -1,418 +1,419 @@
 <?php
 
 /**
  * Given a commit and a path, efficiently determine the most recent ancestor
  * commit where the path was touched.
  *
  * In Git and Mercurial, log operations with a path are relatively slow. For
  * example:
  *
  *    git log -n1 <commit> -- <path>
  *
  * ...routinely takes several hundred milliseconds, and equivalent requests
  * often take longer in Mercurial.
  *
  * Unfortunately, this operation is fundamental to rendering a repository for
  * the web, and essentially everything else that's slow can be reduced to this
  * plus some trivial work afterward. Making this fast is desirable and powerful,
  * and allows us to make other things fast by expressing them in terms of this
  * query.
  *
  * Because the query is fundamentally a graph query, it isn't easy to express
  * in a reasonable way in MySQL, and we can't do round trips to the server to
  * walk the graph without incurring huge performance penalties.
  *
  * However, the total amount of data in the graph is relatively small. By
  * caching it in chunks and keeping it in APC, we can reasonably load and walk
  * the graph in PHP quickly.
  *
  * For more context, see T2683.
  *
  * Structure of the Cache
  * ======================
  *
  * The cache divides commits into buckets (see @{method:getBucketSize}). To
  * walk the graph, we pull a commit's bucket. The bucket is a map from commit
  * IDs to a list of parents and changed paths, separated by `null`. For
  * example, a bucket might look like this:
  *
  *   array(
  *     1 => array(0, null, 17, 18),
  *     2 => array(1, null, 4),
  *     // ...
  *   )
  *
  * This means that commit ID 1 has parent commit 0 (a special value meaning
  * no parents) and affected path IDs 17 and 18. Commit ID 2 has parent commit 1,
  * and affected path 4.
  *
  * This data structure attempts to balance compactness, ease of construction,
  * simplicity of cache semantics, and lookup performance. In the average case,
  * it appears to do a reasonable job at this.
  *
  * @task query Querying the Graph Cache
  * @task cache Cache Internals
  */
 final class PhabricatorRepositoryGraphCache extends Phobject {
 
   private $rebuiltKeys = array();
 
 
 /* -(  Querying the Graph Cache  )------------------------------------------- */
 
 
   /**
    * Search the graph cache for the most modification to a path.
    *
-   * @param int     The commit ID to search ancestors of.
-   * @param int     The path ID to search for changes to.
-   * @param float   Maximum number of seconds to spend trying to satisfy this
-   *                query using the graph cache. By default, `0.5` (500ms).
+   * @param int     $commit_id The commit ID to search ancestors of.
+   * @param int     $path_id The path ID to search for changes to.
+   * @param float   $time Maximum number of seconds to spend trying to satisfy
+   *                this query using the graph cache. By default `0.5` (500ms).
    * @return mixed  Commit ID, or `null` if no ancestors exist, or `false` if
    *                the graph cache was unable to determine the answer.
    * @task query
    */
   public function loadLastModifiedCommitID($commit_id, $path_id, $time = 0.5) {
     $commit_id = (int)$commit_id;
     $path_id = (int)$path_id;
 
     $bucket_data = null;
     $data_key = null;
     $seen = array();
 
     $t_start = microtime(true);
     $iterations = 0;
     while (true) {
       $bucket_key = $this->getBucketKey($commit_id);
 
       if (($data_key != $bucket_key) || $bucket_data === null) {
         $bucket_data = $this->getBucketData($bucket_key);
         $data_key = $bucket_key;
       }
 
       if (empty($bucket_data[$commit_id])) {
         // Rebuild the cache bucket, since the commit might be a very recent
         // one that we'll pick up by rebuilding.
 
         $bucket_data = $this->getBucketData($bucket_key, $bucket_data);
         if (empty($bucket_data[$commit_id])) {
           // A rebuild didn't help. This can occur legitimately if the commit
           // is new and hasn't parsed yet.
           return false;
         }
 
         // Otherwise, the rebuild gave us the data, so we can keep going.
 
         $did_fill = true;
       } else {
         $did_fill = false;
       }
 
       // Sanity check so we can survive and recover from bad data.
       if (isset($seen[$commit_id])) {
         phlog(pht('Unexpected infinite loop in %s!', __CLASS__));
         return false;
       } else {
         $seen[$commit_id] = true;
       }
 
       // `$data` is a list: the commit's parent IDs, followed by `null`,
       // followed by the modified paths in ascending order. We figure out the
       // first parent first, then check if the path was touched. If the path
       // was touched, this is the commit we're after. If not, walk backward
       // in the tree.
 
       $items = $bucket_data[$commit_id];
       $size = count($items);
 
       // Walk past the parent information.
       $parent_id = null;
       for ($ii = 0;; ++$ii) {
         if ($items[$ii] === null) {
           break;
         }
         if ($parent_id === null) {
           $parent_id = $items[$ii];
         }
       }
 
       // Look for a modification to the path.
       for (; $ii < $size; ++$ii) {
         $item = $items[$ii];
         if ($item > $path_id) {
           break;
         }
         if ($item === $path_id) {
           return $commit_id;
         }
       }
 
       if ($parent_id) {
         $commit_id = $parent_id;
 
         // Periodically check if we've spent too long looking for a result
         // in the cache, and return so we can fall back to a VCS operation.
         // This keeps us from having a degenerate worst case if, e.g., the
         // cache is cold and we need to inspect a very large number of blocks
         // to satisfy the query.
 
         ++$iterations;
 
         // If we performed a cache fill in this cycle, always check the time
         // limit, since cache fills may take a significant amount of time.
 
         if ($did_fill || ($iterations % 64 === 0)) {
           $t_end = microtime(true);
           if (($t_end - $t_start) > $time) {
             return false;
           }
         }
         continue;
       }
 
       // If we have an explicit 0, that means this commit really has no parents.
       // Usually, it is the first commit in the repository.
       if ($parent_id === 0) {
         return null;
       }
 
       // If we didn't find a parent, the parent data isn't available. We fail
       // to find an answer in the cache and fall back to querying the VCS.
       return false;
     }
   }
 
 
 /* -(  Cache Internals  )---------------------------------------------------- */
 
 
   /**
    * Get the bucket key for a given commit ID.
    *
-   * @param   int   Commit ID.
+   * @param   int   $commit_id Commit ID.
    * @return  int   Bucket key.
    * @task cache
    */
   private function getBucketKey($commit_id) {
     return (int)floor($commit_id / $this->getBucketSize());
   }
 
 
   /**
    * Get the cache key for a given bucket key (from @{method:getBucketKey}).
    *
-   * @param   int     Bucket key.
+   * @param   int     $bucket_key Bucket key.
    * @return  string  Cache key.
    * @task cache
    */
   private function getBucketCacheKey($bucket_key) {
     static $prefix;
 
     if ($prefix === null) {
       $self = get_class($this);
       $size = $this->getBucketSize();
       $prefix = "{$self}:{$size}:2:";
     }
 
     return $prefix.$bucket_key;
   }
 
 
   /**
    * Get the number of items per bucket.
    *
    * @return  int Number of items to store per bucket.
    * @task cache
    */
   private function getBucketSize() {
     return 4096;
   }
 
 
   /**
    * Retrieve or build a graph cache bucket from the cache.
    *
    * Normally, this operates as a readthrough cache call. It can also be used
    * to force a cache update by passing the existing data to `$rebuild_data`.
    *
-   * @param   int     Bucket key, from @{method:getBucketKey}.
-   * @param   mixed   Current data, to force a cache rebuild of this bucket.
-   * @return  array   Data from the cache.
+   * @param   int    $bucket_key Bucket key, from @{method:getBucketKey}.
+   * @param   mixed? $rebuild_data Current data, to force a cache rebuild of
+   *                 this bucket.
+   * @return  array  Data from the cache.
    * @task cache
    */
   private function getBucketData($bucket_key, $rebuild_data = null) {
     $cache_key = $this->getBucketCacheKey($bucket_key);
 
     // TODO: This cache stuff could be handled more gracefully, but the
     // database cache currently requires values to be strings and needs
     // some tweaking to support this as part of a stack. Our cache semantics
     // here are also unusual (not purely readthrough) because this cache is
     // appendable.
 
     $cache_level1 = PhabricatorCaches::getRepositoryGraphL1Cache();
     $cache_level2 = PhabricatorCaches::getRepositoryGraphL2Cache();
     if ($rebuild_data === null) {
       $bucket_data = $cache_level1->getKey($cache_key);
       if ($bucket_data) {
         return $bucket_data;
       }
 
       $bucket_data = $cache_level2->getKey($cache_key);
       if ($bucket_data) {
         $unserialized = @unserialize($bucket_data);
         if ($unserialized) {
           // Fill APC if we got a database hit but missed in APC.
           $cache_level1->setKey($cache_key, $unserialized);
           return $unserialized;
         }
       }
     }
 
     if (!is_array($rebuild_data)) {
       $rebuild_data = array();
     }
 
     $bucket_data = $this->rebuildBucket($bucket_key, $rebuild_data);
 
     // Don't bother writing the data if we didn't update anything.
     if ($bucket_data !== $rebuild_data) {
       $cache_level2->setKey($cache_key, serialize($bucket_data));
       $cache_level1->setKey($cache_key, $bucket_data);
     }
 
     return $bucket_data;
   }
 
 
   /**
    * Rebuild a cache bucket, amending existing data if available.
    *
-   * @param   int     Bucket key, from @{method:getBucketKey}.
-   * @param   array   Existing bucket data.
-   * @return  array   Rebuilt bucket data.
+   * @param   int   $bucket_key Bucket key, from @{method:getBucketKey}.
+   * @param   array $current_data Existing bucket data.
+   * @return  array Rebuilt bucket data.
    * @task cache
    */
   private function rebuildBucket($bucket_key, array $current_data) {
 
     // First, check if we've already rebuilt this bucket. In some cases (like
     // browsing a repository at some commit) it's common to issue many lookups
     // against one commit. If that commit has been discovered but not yet
     // fully imported, we'll repeatedly attempt to rebuild the bucket. If the
     // first rebuild did not work, subsequent rebuilds are very unlikely to
     // have any effect. We can just skip the rebuild in these cases.
 
     if (isset($this->rebuiltKeys[$bucket_key])) {
       return $current_data;
     } else {
       $this->rebuiltKeys[$bucket_key] = true;
     }
 
     $bucket_min = ($bucket_key * $this->getBucketSize());
     $bucket_max = ($bucket_min + $this->getBucketSize()) - 1;
 
     // We need to reload all of the commits in the bucket because there is
     // no guarantee that they'll get parsed in order, so we can fill large
     // commit IDs before small ones. Later on, we'll ignore the commits we
     // already know about.
 
     $table_commit = new PhabricatorRepositoryCommit();
     $table_repository = new PhabricatorRepository();
     $conn_r = $table_commit->establishConnection('r');
 
     // Find all the Git and Mercurial commits in the block which have completed
     // change import. We can't fill the cache accurately for commits which have
     // not completed change import, so just pretend we don't know about them.
     // In these cases, we will ultimately fall back to VCS queries.
 
     $commit_rows = queryfx_all(
       $conn_r,
       'SELECT c.id FROM %T c
         JOIN %T r ON c.repositoryID = r.id AND r.versionControlSystem IN (%Ls)
         WHERE c.id BETWEEN %d AND %d
           AND (c.importStatus & %d) = %d',
       $table_commit->getTableName(),
       $table_repository->getTableName(),
       array(
         PhabricatorRepositoryType::REPOSITORY_TYPE_GIT,
         PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL,
       ),
       $bucket_min,
       $bucket_max,
       PhabricatorRepositoryCommit::IMPORTED_CHANGE,
       PhabricatorRepositoryCommit::IMPORTED_CHANGE);
 
     // If we don't have any data, just return the existing data.
     if (!$commit_rows) {
       return $current_data;
     }
 
     // Remove the commits we already have data for. We don't need to rebuild
     // these. If there's nothing left, return the existing data.
 
     $commit_ids = ipull($commit_rows, 'id', 'id');
     $commit_ids = array_diff_key($commit_ids, $current_data);
 
     if (!$commit_ids) {
       return $current_data;
     }
 
     // Find all the path changes for the new commits.
     $path_changes = queryfx_all(
       $conn_r,
       'SELECT commitID, pathID FROM %T
         WHERE commitID IN (%Ld)
         AND (isDirect = 1 OR changeType = %d)',
       PhabricatorRepository::TABLE_PATHCHANGE,
       $commit_ids,
       DifferentialChangeType::TYPE_CHILD);
     $path_changes = igroup($path_changes, 'commitID');
 
     // Find all the parents for the new commits.
     $parents = queryfx_all(
       $conn_r,
       'SELECT childCommitID, parentCommitID FROM %T
         WHERE childCommitID IN (%Ld)
         ORDER BY id ASC',
       PhabricatorRepository::TABLE_PARENTS,
       $commit_ids);
     $parents = igroup($parents, 'childCommitID');
 
     // Build the actual data for the cache.
     foreach ($commit_ids as $commit_id) {
       $parent_ids = array();
       if (!empty($parents[$commit_id])) {
         foreach ($parents[$commit_id] as $row) {
           $parent_ids[] = (int)$row['parentCommitID'];
         }
       } else {
         // We expect all rows to have parents (commits with no parents get
         // an explicit "0" placeholder). If we're in an older repository, the
         // parent information might not have been populated yet. Decline to fill
         // the cache if we don't have the parent information, since the fill
         // will be incorrect.
         continue;
       }
 
       if (isset($path_changes[$commit_id])) {
         $path_ids = $path_changes[$commit_id];
         foreach ($path_ids as $key => $path_id) {
           $path_ids[$key] = (int)$path_id['pathID'];
         }
         sort($path_ids);
       } else {
         $path_ids = array();
       }
 
       $value = $parent_ids;
       $value[] = null;
       foreach ($path_ids as $path_id) {
         $value[] = $path_id;
       }
 
       $current_data[$commit_id] = $value;
     }
 
     return $current_data;
   }
 
 }
diff --git a/src/applications/repository/storage/PhabricatorRepository.php b/src/applications/repository/storage/PhabricatorRepository.php
index b2855c0c06..b9aab043ad 100644
--- a/src/applications/repository/storage/PhabricatorRepository.php
+++ b/src/applications/repository/storage/PhabricatorRepository.php
@@ -1,2885 +1,2885 @@
 <?php
 
 /**
  * @task uri        Repository URI Management
  * @task publishing Publishing
  * @task sync       Cluster Synchronization
  */
 final class PhabricatorRepository extends PhabricatorRepositoryDAO
   implements
     PhabricatorApplicationTransactionInterface,
     PhabricatorPolicyInterface,
     PhabricatorFlaggableInterface,
     PhabricatorMarkupInterface,
     PhabricatorDestructibleInterface,
     PhabricatorDestructibleCodexInterface,
     PhabricatorProjectInterface,
     PhabricatorSpacesInterface,
     PhabricatorConduitResultInterface,
     PhabricatorFulltextInterface,
     PhabricatorFerretInterface {
 
   /**
    * Shortest hash we'll recognize in raw "a829f32" form.
    */
   const MINIMUM_UNQUALIFIED_HASH = 7;
 
   /**
    * Shortest hash we'll recognize in qualified "rXab7ef2f8" form.
    */
   const MINIMUM_QUALIFIED_HASH = 5;
 
   /**
    * Minimum number of commits to an empty repository to trigger "import" mode.
    */
   const IMPORT_THRESHOLD = 7;
 
   const LOWPRI_THRESHOLD = 64;
 
   const TABLE_PATH = 'repository_path';
   const TABLE_PATHCHANGE = 'repository_pathchange';
   const TABLE_FILESYSTEM = 'repository_filesystem';
   const TABLE_SUMMARY = 'repository_summary';
   const TABLE_LINTMESSAGE = 'repository_lintmessage';
   const TABLE_PARENTS = 'repository_parents';
   const TABLE_COVERAGE = 'repository_coverage';
 
   const STATUS_ACTIVE = 'active';
   const STATUS_INACTIVE = 'inactive';
 
   protected $name;
   protected $callsign;
   protected $repositorySlug;
   protected $uuid;
   protected $viewPolicy;
   protected $editPolicy;
   protected $pushPolicy;
   protected $profileImagePHID;
 
   protected $versionControlSystem;
   protected $details = array();
   protected $credentialPHID;
   protected $almanacServicePHID;
   protected $spacePHID;
   protected $localPath;
 
   private $commitCount = self::ATTACHABLE;
   private $mostRecentCommit = self::ATTACHABLE;
   private $projectPHIDs = self::ATTACHABLE;
   private $uris = self::ATTACHABLE;
   private $profileImageFile = self::ATTACHABLE;
 
 
   public static function initializeNewRepository(PhabricatorUser $actor) {
     $app = id(new PhabricatorApplicationQuery())
       ->setViewer($actor)
       ->withClasses(array('PhabricatorDiffusionApplication'))
       ->executeOne();
 
     $view_policy = $app->getPolicy(DiffusionDefaultViewCapability::CAPABILITY);
     $edit_policy = $app->getPolicy(DiffusionDefaultEditCapability::CAPABILITY);
     $push_policy = $app->getPolicy(DiffusionDefaultPushCapability::CAPABILITY);
 
     $repository = id(new PhabricatorRepository())
       ->setViewPolicy($view_policy)
       ->setEditPolicy($edit_policy)
       ->setPushPolicy($push_policy)
       ->setSpacePHID($actor->getDefaultSpacePHID());
 
     // Put the repository in "Importing" mode until we finish
     // parsing it.
     $repository->setDetail('importing', true);
 
     return $repository;
   }
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_SERIALIZATION => array(
         'details' => self::SERIALIZATION_JSON,
       ),
       self::CONFIG_COLUMN_SCHEMA => array(
         'name' => 'sort255',
         'callsign' => 'sort32?',
         'repositorySlug' => 'sort64?',
         'versionControlSystem' => 'text32',
         'uuid' => 'text64?',
         'pushPolicy' => 'policy',
         'credentialPHID' => 'phid?',
         'almanacServicePHID' => 'phid?',
         'localPath' => 'text128?',
         'profileImagePHID' => 'phid?',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'callsign' => array(
           'columns' => array('callsign'),
           'unique' => true,
         ),
         'key_name' => array(
           'columns' => array('name(128)'),
         ),
         'key_vcs' => array(
           'columns' => array('versionControlSystem'),
         ),
         'key_slug' => array(
           'columns' => array('repositorySlug'),
           'unique' => true,
         ),
         'key_local' => array(
           'columns' => array('localPath'),
           'unique' => true,
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function generatePHID() {
     return PhabricatorPHID::generateNewPHID(
       PhabricatorRepositoryRepositoryPHIDType::TYPECONST);
   }
 
   public static function getStatusMap() {
     return array(
       self::STATUS_ACTIVE => array(
         'name' => pht('Active'),
         'isTracked' => 1,
       ),
       self::STATUS_INACTIVE => array(
         'name' => pht('Inactive'),
         'isTracked' => 0,
       ),
     );
   }
 
   public static function getStatusNameMap() {
     return ipull(self::getStatusMap(), 'name');
   }
 
   public function getStatus() {
     if ($this->isTracked()) {
       return self::STATUS_ACTIVE;
     } else {
       return self::STATUS_INACTIVE;
     }
   }
 
   public function toDictionary() {
     return array(
       'id'          => $this->getID(),
       'name'        => $this->getName(),
       'phid'        => $this->getPHID(),
       'callsign'    => $this->getCallsign(),
       'monogram'    => $this->getMonogram(),
       'vcs'         => $this->getVersionControlSystem(),
       'uri'         => PhabricatorEnv::getProductionURI($this->getURI()),
       'remoteURI'   => (string)$this->getRemoteURI(),
       'description' => $this->getDetail('description'),
       'isActive'    => $this->isTracked(),
       'isHosted'    => $this->isHosted(),
       'isImporting' => $this->isImporting(),
       'encoding'    => $this->getDefaultTextEncoding(),
       'staging' => array(
         'supported' => $this->supportsStaging(),
         'prefix' => 'phabricator',
         'uri' => $this->getStagingURI(),
       ),
     );
   }
 
   public function getDefaultTextEncoding() {
     return $this->getDetail('encoding', 'UTF-8');
   }
 
   public function getMonogram() {
     $callsign = $this->getCallsign();
     if (phutil_nonempty_string($callsign)) {
       return "r{$callsign}";
     }
 
     $id = $this->getID();
     return "R{$id}";
   }
 
   public function getDisplayName() {
     $slug = $this->getRepositorySlug();
 
     if (phutil_nonempty_string($slug)) {
       return $slug;
     }
 
     return $this->getMonogram();
   }
 
   public function getAllMonograms() {
     $monograms = array();
 
     $monograms[] = 'R'.$this->getID();
 
     $callsign = $this->getCallsign();
     if (phutil_nonempty_string($callsign)) {
       $monograms[] = 'r'.$callsign;
     }
 
     return $monograms;
   }
 
   public function setLocalPath($path) {
     // Convert any extra slashes ("//") in the path to a single slash ("/").
     $path = preg_replace('(//+)', '/', $path);
 
     return parent::setLocalPath($path);
   }
 
   public function getDetail($key, $default = null) {
     return idx($this->details, $key, $default);
   }
 
   public function setDetail($key, $value) {
     $this->details[$key] = $value;
     return $this;
   }
 
   public function attachCommitCount($count) {
     $this->commitCount = $count;
     return $this;
   }
 
   public function getCommitCount() {
     return $this->assertAttached($this->commitCount);
   }
 
   public function attachMostRecentCommit(
     PhabricatorRepositoryCommit $commit = null) {
     $this->mostRecentCommit = $commit;
     return $this;
   }
 
   public function getMostRecentCommit() {
     return $this->assertAttached($this->mostRecentCommit);
   }
 
   public function getDiffusionBrowseURIForPath(
     PhabricatorUser $user,
     $path,
     $line = null,
     $branch = null) {
 
     $drequest = DiffusionRequest::newFromDictionary(
       array(
         'user' => $user,
         'repository' => $this,
         'path' => $path,
         'branch' => $branch,
       ));
 
     return $drequest->generateURI(
       array(
         'action' => 'browse',
         'line'   => $line,
       ));
   }
 
   public function getSubversionBaseURI($commit = null) {
     $subpath = $this->getDetail('svn-subpath');
 
     if (!phutil_nonempty_string($subpath)) {
       $subpath = null;
     }
 
     return $this->getSubversionPathURI($subpath, $commit);
   }
 
   public function getSubversionPathURI($path = null, $commit = null) {
     $vcs = $this->getVersionControlSystem();
     if ($vcs != PhabricatorRepositoryType::REPOSITORY_TYPE_SVN) {
       throw new Exception(pht('Not a subversion repository!'));
     }
 
     if ($this->isHosted()) {
       $uri = 'file://'.$this->getLocalPath();
     } else {
       $uri = $this->getDetail('remote-uri');
     }
 
     $uri = rtrim($uri, '/');
 
     if (phutil_nonempty_string($path)) {
       $path = rawurlencode($path);
       $path = str_replace('%2F', '/', $path);
       $uri = $uri.'/'.ltrim($path, '/');
     }
 
     if ($path !== null || $commit !== null) {
       $uri .= '@';
     }
 
     if ($commit !== null) {
       $uri .= $commit;
     }
 
     return $uri;
   }
 
   public function attachProjectPHIDs(array $project_phids) {
     $this->projectPHIDs = $project_phids;
     return $this;
   }
 
   public function getProjectPHIDs() {
     return $this->assertAttached($this->projectPHIDs);
   }
 
 
   /**
    * Get the name of the directory this repository should clone or checkout
    * into. For example, if the repository name is "Example Repository", a
    * reasonable name might be "example-repository". This is used to help users
    * get reasonable results when cloning repositories, since they generally do
    * not want to clone into directories called "X/" or "Example Repository/".
    *
    * @return string
    */
   public function getCloneName() {
     $name = $this->getRepositorySlug();
 
     // Make some reasonable effort to produce reasonable default directory
     // names from repository names.
     if (!phutil_nonempty_string($name)) {
       $name = $this->getName();
       $name = phutil_utf8_strtolower($name);
       $name = preg_replace('@[ -/:->]+@', '-', $name);
       $name = trim($name, '-');
       if (!phutil_nonempty_string($name)) {
         $name = $this->getCallsign();
       }
     }
 
     return $name;
   }
 
   public static function isValidRepositorySlug($slug) {
     try {
       self::assertValidRepositorySlug($slug);
       return true;
     } catch (Exception $ex) {
       return false;
     }
   }
 
   public static function assertValidRepositorySlug($slug) {
     if (!strlen($slug)) {
       throw new Exception(
         pht(
           'The empty string is not a valid repository short name. '.
           'Repository short names must be at least one character long.'));
     }
 
     if (strlen($slug) > 64) {
       throw new Exception(
         pht(
           'The name "%s" is not a valid repository short name. Repository '.
           'short names must not be longer than 64 characters.',
           $slug));
     }
 
     if (preg_match('/[^a-zA-Z0-9._-]/', $slug)) {
       throw new Exception(
         pht(
           'The name "%s" is not a valid repository short name. Repository '.
           'short names may only contain letters, numbers, periods, hyphens '.
           'and underscores.',
           $slug));
     }
 
     if (!preg_match('/^[a-zA-Z0-9]/', $slug)) {
       throw new Exception(
         pht(
           'The name "%s" is not a valid repository short name. Repository '.
           'short names must begin with a letter or number.',
           $slug));
     }
 
     if (!preg_match('/[a-zA-Z0-9]\z/', $slug)) {
       throw new Exception(
         pht(
           'The name "%s" is not a valid repository short name. Repository '.
           'short names must end with a letter or number.',
           $slug));
     }
 
     if (preg_match('/__|--|\\.\\./', $slug)) {
       throw new Exception(
         pht(
           'The name "%s" is not a valid repository short name. Repository '.
           'short names must not contain multiple consecutive underscores, '.
           'hyphens, or periods.',
           $slug));
     }
 
     if (preg_match('/^[A-Z]+\z/', $slug)) {
       throw new Exception(
         pht(
           'The name "%s" is not a valid repository short name. Repository '.
           'short names may not contain only uppercase letters.',
           $slug));
     }
 
     if (preg_match('/^\d+\z/', $slug)) {
       throw new Exception(
         pht(
           'The name "%s" is not a valid repository short name. Repository '.
           'short names may not contain only numbers.',
           $slug));
     }
 
     if (preg_match('/\\.git/', $slug)) {
       throw new Exception(
         pht(
           'The name "%s" is not a valid repository short name. Repository '.
           'short names must not end in ".git". This suffix will be added '.
           'automatically in appropriate contexts.',
           $slug));
     }
   }
 
   public static function assertValidCallsign($callsign) {
     if (!strlen($callsign)) {
       throw new Exception(
         pht(
           'A repository callsign must be at least one character long.'));
     }
 
     if (strlen($callsign) > 32) {
       throw new Exception(
         pht(
           'The callsign "%s" is not a valid repository callsign. Callsigns '.
           'must be no more than 32 bytes long.',
           $callsign));
     }
 
     if (!preg_match('/^[A-Z]+\z/', $callsign)) {
       throw new Exception(
         pht(
           'The callsign "%s" is not a valid repository callsign. Callsigns '.
           'may only contain UPPERCASE letters.',
           $callsign));
     }
   }
 
   public function getProfileImageURI() {
     return $this->getProfileImageFile()->getBestURI();
   }
 
   public function attachProfileImageFile(PhabricatorFile $file) {
     $this->profileImageFile = $file;
     return $this;
   }
 
   public function getProfileImageFile() {
     return $this->assertAttached($this->profileImageFile);
   }
 
 
 
 /* -(  Remote Command Execution  )------------------------------------------- */
 
 
   public function execRemoteCommand($pattern /* , $arg, ... */) {
     $args = func_get_args();
     return $this->newRemoteCommandFuture($args)->resolve();
   }
 
   public function execxRemoteCommand($pattern /* , $arg, ... */) {
     $args = func_get_args();
     return $this->newRemoteCommandFuture($args)->resolvex();
   }
 
   public function getRemoteCommandFuture($pattern /* , $arg, ... */) {
     $args = func_get_args();
     return $this->newRemoteCommandFuture($args);
   }
 
   public function passthruRemoteCommand($pattern /* , $arg, ... */) {
     $args = func_get_args();
     return $this->newRemoteCommandPassthru($args)->resolve();
   }
 
   private function newRemoteCommandFuture(array $argv) {
     return $this->newRemoteCommandEngine($argv)
       ->newFuture();
   }
 
   private function newRemoteCommandPassthru(array $argv) {
     return $this->newRemoteCommandEngine($argv)
       ->setPassthru(true)
       ->newFuture();
   }
 
   private function newRemoteCommandEngine(array $argv) {
     return DiffusionCommandEngine::newCommandEngine($this)
       ->setArgv($argv)
       ->setCredentialPHID($this->getCredentialPHID())
       ->setURI($this->getRemoteURIObject());
   }
 
 /* -(  Local Command Execution  )-------------------------------------------- */
 
 
   public function execLocalCommand($pattern /* , $arg, ... */) {
     $args = func_get_args();
     return $this->newLocalCommandFuture($args)->resolve();
   }
 
   public function execxLocalCommand($pattern /* , $arg, ... */) {
     $args = func_get_args();
     return $this->newLocalCommandFuture($args)->resolvex();
   }
 
   public function getLocalCommandFuture($pattern /* , $arg, ... */) {
     $args = func_get_args();
     return $this->newLocalCommandFuture($args);
   }
 
   public function passthruLocalCommand($pattern /* , $arg, ... */) {
     $args = func_get_args();
     return $this->newLocalCommandPassthru($args)->resolve();
   }
 
   private function newLocalCommandFuture(array $argv) {
     $this->assertLocalExists();
 
     $future = DiffusionCommandEngine::newCommandEngine($this)
       ->setArgv($argv)
       ->newFuture();
 
     if ($this->usesLocalWorkingCopy()) {
       $future->setCWD($this->getLocalPath());
     }
 
     return $future;
   }
 
   private function newLocalCommandPassthru(array $argv) {
     $this->assertLocalExists();
 
     $future = DiffusionCommandEngine::newCommandEngine($this)
       ->setArgv($argv)
       ->setPassthru(true)
       ->newFuture();
 
     if ($this->usesLocalWorkingCopy()) {
       $future->setCWD($this->getLocalPath());
     }
 
     return $future;
   }
 
   public function getURI() {
     $short_name = $this->getRepositorySlug();
     if (phutil_nonempty_string($short_name)) {
       return "/source/{$short_name}/";
     }
 
     $callsign = $this->getCallsign();
     if (phutil_nonempty_string($callsign)) {
       return "/diffusion/{$callsign}/";
     }
 
     $id = $this->getID();
     return "/diffusion/{$id}/";
   }
 
   public function getPathURI($path) {
     return $this->getURI().ltrim($path, '/');
   }
 
   public function getCommitURI($identifier) {
     $callsign = $this->getCallsign();
     if (phutil_nonempty_string($callsign)) {
       return "/r{$callsign}{$identifier}";
     }
 
     $id = $this->getID();
     return "/R{$id}:{$identifier}";
   }
 
   public static function parseRepositoryServicePath($request_path, $vcs) {
     $is_git = ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_GIT);
 
     $patterns = array(
       '(^'.
         '(?P<base>/?(?:diffusion|source)/(?P<identifier>[^/]+))'.
         '(?P<path>.*)'.
       '\z)',
     );
 
     $identifier = null;
     foreach ($patterns as $pattern) {
       $matches = null;
       if (!preg_match($pattern, $request_path, $matches)) {
         continue;
       }
 
       $identifier = $matches['identifier'];
       if ($is_git) {
         $identifier = preg_replace('/\\.git\z/', '', $identifier);
       }
 
       $base = $matches['base'];
       $path = $matches['path'];
       break;
     }
 
     if ($identifier === null) {
       return null;
     }
 
     return array(
       'identifier' => $identifier,
       'base' => $base,
       'path' => $path,
     );
   }
 
   public function getCanonicalPath($request_path) {
     $standard_pattern =
       '(^'.
         '(?P<prefix>/(?:diffusion|source)/)'.
         '(?P<identifier>[^/]+)'.
         '(?P<suffix>(?:/.*)?)'.
       '\z)';
 
     $matches = null;
     if (preg_match($standard_pattern, $request_path, $matches)) {
       $suffix = $matches['suffix'];
       return $this->getPathURI($suffix);
     }
 
     $commit_pattern =
       '(^'.
         '(?P<prefix>/)'.
         '(?P<monogram>'.
           '(?:'.
             'r(?P<repositoryCallsign>[A-Z]+)'.
             '|'.
             'R(?P<repositoryID>[1-9]\d*):'.
           ')'.
           '(?P<commit>[a-f0-9]+)'.
         ')'.
       '\z)';
 
     $matches = null;
     if (preg_match($commit_pattern, $request_path, $matches)) {
       $commit = $matches['commit'];
       return $this->getCommitURI($commit);
     }
 
     return null;
   }
 
   public function generateURI(array $params) {
     $req_branch = false;
     $req_commit = false;
 
     $action = idx($params, 'action');
     switch ($action) {
       case 'history':
       case 'clone':
       case 'blame':
       case 'browse':
       case 'document':
       case 'change':
       case 'lastmodified':
       case 'tags':
       case 'branches':
       case 'lint':
       case 'pathtree':
       case 'refs':
       case 'compare':
         break;
       case 'branch':
         // NOTE: This does not actually require a branch, and won't have one
         // in Subversion. Possibly this should be more clear.
         break;
       case 'commit':
       case 'rendering-ref':
         $req_commit = true;
         break;
       default:
         throw new Exception(
           pht(
             'Action "%s" is not a valid repository URI action.',
             $action));
     }
 
     $path     = idx($params, 'path');
     $branch   = idx($params, 'branch');
     $commit   = idx($params, 'commit');
     $line     = idx($params, 'line');
 
     $head = idx($params, 'head');
     $against = idx($params, 'against');
 
     if ($req_commit && !strlen($commit)) {
       throw new Exception(
         pht(
           'Diffusion URI action "%s" requires commit!',
           $action));
     }
 
     if ($req_branch && !strlen($branch)) {
       throw new Exception(
         pht(
           'Diffusion URI action "%s" requires branch!',
           $action));
     }
 
     if ($action === 'commit') {
       return $this->getCommitURI($commit);
     }
 
     if (phutil_nonempty_string($path)) {
       $path = ltrim($path, '/');
       $path = str_replace(array(';', '$'), array(';;', '$$'), $path);
       $path = phutil_escape_uri($path);
     }
 
     $raw_branch = $branch;
     if (phutil_nonempty_string($branch)) {
       $branch = phutil_escape_uri_path_component($branch);
       $path = "{$branch}/{$path}";
     }
 
     $raw_commit = $commit;
     if (phutil_nonempty_scalar($commit)) {
       $commit = str_replace('$', '$$', $commit);
       $commit = ';'.phutil_escape_uri($commit);
     }
 
     $line = phutil_string_cast($line);
     if (phutil_nonempty_string($line)) {
       $line = '$'.phutil_escape_uri($line);
     }
 
     $query = array();
     switch ($action) {
       case 'change':
       case 'history':
       case 'blame':
       case 'browse':
       case 'document':
       case 'lastmodified':
       case 'tags':
       case 'branches':
       case 'lint':
       case 'pathtree':
       case 'refs':
         $uri = $this->getPathURI("/{$action}/{$path}{$commit}{$line}");
         break;
       case 'compare':
         $uri = $this->getPathURI("/{$action}/");
         if (phutil_nonempty_scalar($head)) {
           $query['head'] = $head;
         } else if (phutil_nonempty_scalar($raw_commit)) {
           $query['commit'] = $raw_commit;
         } else if (phutil_nonempty_scalar($raw_branch)) {
           $query['head'] = $raw_branch;
         }
 
         if (phutil_nonempty_scalar($against)) {
           $query['against'] = $against;
         }
         break;
       case 'branch':
         if (strlen($path)) {
           $uri = $this->getPathURI("/repository/{$path}");
         } else {
           $uri = $this->getPathURI('/');
         }
         break;
       case 'external':
         $commit = ltrim($commit, ';');
         $uri = "/diffusion/external/{$commit}/";
         break;
       case 'rendering-ref':
         // This isn't a real URI per se, it's passed as a query parameter to
         // the ajax changeset stuff but then we parse it back out as though
         // it came from a URI.
         $uri = rawurldecode("{$path}{$commit}");
         break;
       case 'clone':
         $uri = $this->getPathURI("/{$action}/");
       break;
     }
 
     if ($action == 'rendering-ref') {
       return $uri;
     }
 
     if (isset($params['lint'])) {
       $params['params'] = idx($params, 'params', array()) + array(
         'lint' => $params['lint'],
       );
     }
 
     $query = idx($params, 'params', array()) + $query;
 
     return new PhutilURI($uri, $query);
   }
 
   public function updateURIIndex() {
     $indexes = array();
 
     $uris = $this->getURIs();
     foreach ($uris as $uri) {
       if ($uri->getIsDisabled()) {
         continue;
       }
 
       $indexes[] = $uri->getNormalizedURI();
     }
 
     PhabricatorRepositoryURIIndex::updateRepositoryURIs(
       $this->getPHID(),
       $indexes);
 
     return $this;
   }
 
   public function isTracked() {
     $status = $this->getDetail('tracking-enabled');
     $map = self::getStatusMap();
     $spec = idx($map, $status);
 
     if (!$spec) {
       if ($status) {
         $status = self::STATUS_ACTIVE;
       } else {
         $status = self::STATUS_INACTIVE;
       }
       $spec = idx($map, $status);
     }
 
     return (bool)idx($spec, 'isTracked', false);
   }
 
   public function getDefaultBranch() {
     $default = $this->getDetail('default-branch');
     if (phutil_nonempty_string($default)) {
       return $default;
     }
 
     $default_branches = array(
       PhabricatorRepositoryType::REPOSITORY_TYPE_GIT        => 'master',
       PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL  => 'default',
     );
 
     return idx($default_branches, $this->getVersionControlSystem());
   }
 
   public function getDefaultArcanistBranch() {
     return coalesce($this->getDefaultBranch(), 'svn');
   }
 
   private function isBranchInFilter($branch, $filter_key) {
     $vcs = $this->getVersionControlSystem();
 
     $is_git = ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_GIT);
 
     $use_filter = ($is_git);
     if (!$use_filter) {
       // If this VCS doesn't use filters, pass everything through.
       return true;
     }
 
 
     $filter = $this->getDetail($filter_key, array());
 
     // If there's no filter set, let everything through.
     if (!$filter) {
       return true;
     }
 
     // If this branch isn't literally named `regexp(...)`, and it's in the
     // filter list, let it through.
     if (isset($filter[$branch])) {
       if (self::extractBranchRegexp($branch) === null) {
         return true;
       }
     }
 
     // If the branch matches a regexp, let it through.
     foreach ($filter as $pattern => $ignored) {
       $regexp = self::extractBranchRegexp($pattern);
       if ($regexp !== null) {
         if (preg_match($regexp, $branch)) {
           return true;
         }
       }
     }
 
     // Nothing matched, so filter this branch out.
     return false;
   }
 
   public static function extractBranchRegexp($pattern) {
     $matches = null;
     if (preg_match('/^regexp\\((.*)\\)\z/', $pattern, $matches)) {
       return $matches[1];
     }
     return null;
   }
 
   public function shouldTrackRef(DiffusionRepositoryRef $ref) {
     // At least for now, don't track the staging area tags.
     if ($ref->isTag()) {
       if (preg_match('(^phabricator/)', $ref->getShortName())) {
         return false;
       }
     }
 
     if (!$ref->isBranch()) {
       return true;
     }
 
     return $this->shouldTrackBranch($ref->getShortName());
   }
 
   public function shouldTrackBranch($branch) {
     return $this->isBranchInFilter($branch, 'branch-filter');
   }
 
   public function isBranchPermanentRef($branch) {
     return $this->isBranchInFilter($branch, 'close-commits-filter');
   }
 
   public function formatCommitName($commit_identifier, $local = false) {
     $vcs = $this->getVersionControlSystem();
 
     $type_git = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;
     $type_hg = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL;
 
     $is_git = ($vcs == $type_git);
     $is_hg = ($vcs == $type_hg);
     if ($is_git || $is_hg) {
       $name = substr($commit_identifier, 0, 12);
       $need_scope = false;
     } else {
       $name = $commit_identifier;
       $need_scope = true;
     }
 
     if (!$local) {
       $need_scope = true;
     }
 
     if ($need_scope) {
       $callsign = $this->getCallsign();
       if ($callsign) {
         $scope = "r{$callsign}";
       } else {
         $id = $this->getID();
         $scope = "R{$id}:";
       }
       $name = $scope.$name;
     }
 
     return $name;
   }
 
   public function isImporting() {
     return (bool)$this->getDetail('importing', false);
   }
 
   public function isNewlyInitialized() {
     return (bool)$this->getDetail('newly-initialized', false);
   }
 
   public function loadImportProgress() {
     $progress = queryfx_all(
       $this->establishConnection('r'),
       'SELECT importStatus, count(*) N FROM %T WHERE repositoryID = %d
         GROUP BY importStatus',
       id(new PhabricatorRepositoryCommit())->getTableName(),
       $this->getID());
 
     $done = 0;
     $total = 0;
     foreach ($progress as $row) {
       $total += $row['N'] * 3;
       $status = $row['importStatus'];
       if ($status & PhabricatorRepositoryCommit::IMPORTED_MESSAGE) {
         $done += $row['N'];
       }
       if ($status & PhabricatorRepositoryCommit::IMPORTED_CHANGE) {
         $done += $row['N'];
       }
       if ($status & PhabricatorRepositoryCommit::IMPORTED_PUBLISH) {
         $done += $row['N'];
       }
     }
 
     if ($total) {
       $ratio = ($done / $total);
     } else {
       $ratio = 0;
     }
 
     // Cap this at "99.99%", because it's confusing to users when the actual
     // fraction is "99.996%" and it rounds up to "100.00%".
     if ($ratio > 0.9999) {
       $ratio = 0.9999;
     }
 
     return $ratio;
   }
 
 /* -(  Publishing  )--------------------------------------------------------- */
 
   public function newPublisher() {
     return id(new PhabricatorRepositoryPublisher())
       ->setRepository($this);
   }
 
   public function isPublishingDisabled() {
     return $this->getDetail('herald-disabled');
   }
 
   public function getPermanentRefRules() {
     return array_keys($this->getDetail('close-commits-filter', array()));
   }
 
   public function setPermanentRefRules(array $rules) {
     $rules = array_fill_keys($rules, true);
     $this->setDetail('close-commits-filter', $rules);
     return $this;
   }
 
   public function getTrackOnlyRules() {
     return array_keys($this->getDetail('branch-filter', array()));
   }
 
   public function setTrackOnlyRules(array $rules) {
     $rules = array_fill_keys($rules, true);
     $this->setDetail('branch-filter', $rules);
     return $this;
   }
 
   public function supportsFetchRules() {
     if ($this->isGit()) {
       return true;
     }
 
     return false;
   }
 
   public function getFetchRules() {
     return $this->getDetail('fetch-rules', array());
   }
 
   public function setFetchRules(array $rules) {
     return $this->setDetail('fetch-rules', $rules);
   }
 
 
 /* -(  Repository URI Management  )------------------------------------------ */
 
 
   /**
    * Get the remote URI for this repository.
    *
    * @return string
    * @task uri
    */
   public function getRemoteURI() {
     return (string)$this->getRemoteURIObject();
   }
 
 
   /**
    * Get the remote URI for this repository, including credentials if they're
    * used by this repository.
    *
    * @return PhutilOpaqueEnvelope URI, possibly including credentials.
    * @task uri
    */
   public function getRemoteURIEnvelope() {
     $uri = $this->getRemoteURIObject();
 
     $remote_protocol = $this->getRemoteProtocol();
     if ($remote_protocol == 'http' || $remote_protocol == 'https') {
       // For SVN, we use `--username` and `--password` flags separately, so
       // don't add any credentials here.
       if (!$this->isSVN()) {
         $credential_phid = $this->getCredentialPHID();
         if ($credential_phid) {
           $key = PassphrasePasswordKey::loadFromPHID(
             $credential_phid,
             PhabricatorUser::getOmnipotentUser());
 
           $uri->setUser($key->getUsernameEnvelope()->openEnvelope());
           $uri->setPass($key->getPasswordEnvelope()->openEnvelope());
         }
       }
     }
 
     return new PhutilOpaqueEnvelope((string)$uri);
   }
 
 
   /**
    * Get the clone (or checkout) URI for this repository, without authentication
    * information.
    *
    * @return string Repository URI.
    * @task uri
    */
   public function getPublicCloneURI() {
     return (string)$this->getCloneURIObject();
   }
 
 
   /**
    * Get the protocol for the repository's remote.
    *
    * @return string Protocol, like "ssh" or "git".
    * @task uri
    */
   public function getRemoteProtocol() {
     $uri = $this->getRemoteURIObject();
     return $uri->getProtocol();
   }
 
 
   /**
    * Get a parsed object representation of the repository's remote URI..
    *
    * @return wild A @{class@arcanist:PhutilURI}.
    * @task uri
    */
   public function getRemoteURIObject() {
     $raw_uri = $this->getDetail('remote-uri');
     if (!phutil_nonempty_string($raw_uri)) {
       return new PhutilURI('');
     }
 
     if (!strncmp($raw_uri, '/', 1)) {
       return new PhutilURI('file://'.$raw_uri);
     }
 
     return new PhutilURI($raw_uri);
   }
 
 
   /**
    * Get the "best" clone/checkout URI for this repository, on any protocol.
    */
   public function getCloneURIObject() {
     if (!$this->isHosted()) {
       if ($this->isSVN()) {
         // Make sure we pick up the "Import Only" path for Subversion, so
         // the user clones the repository starting at the correct path, not
         // from the root.
         $base_uri = $this->getSubversionBaseURI();
         $base_uri = new PhutilURI($base_uri);
         $path = $base_uri->getPath();
         if (!$path) {
           $path = '/';
         }
 
         // If the trailing "@" is not required to escape the URI, strip it for
         // readability.
         if (!preg_match('/@.*@/', $path)) {
           $path = rtrim($path, '@');
         }
 
         $base_uri->setPath($path);
         return $base_uri;
       } else {
         return $this->getRemoteURIObject();
       }
     }
 
     // TODO: This should be cleaned up to deal with all the new URI handling.
     $another_copy = id(new PhabricatorRepositoryQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withPHIDs(array($this->getPHID()))
       ->needURIs(true)
       ->executeOne();
 
     $clone_uris = $another_copy->getCloneURIs();
     if (!$clone_uris) {
       return null;
     }
 
     return head($clone_uris)->getEffectiveURI();
   }
 
   private function getRawHTTPCloneURIObject() {
     $uri = PhabricatorEnv::getProductionURI($this->getURI());
     $uri = new PhutilURI($uri);
 
     if ($this->isGit()) {
       $uri->setPath($uri->getPath().$this->getCloneName().'.git');
     } else if ($this->isHg()) {
       $uri->setPath($uri->getPath().$this->getCloneName().'/');
     }
 
     return $uri;
   }
 
 
   /**
    * Determine if we should connect to the remote using SSH flags and
    * credentials.
    *
    * @return bool True to use the SSH protocol.
    * @task uri
    */
   private function shouldUseSSH() {
     if ($this->isHosted()) {
       return false;
     }
 
     $protocol = $this->getRemoteProtocol();
     if ($this->isSSHProtocol($protocol)) {
       return true;
     }
 
     return false;
   }
 
 
   /**
    * Determine if we should connect to the remote using HTTP flags and
    * credentials.
    *
    * @return bool True to use the HTTP protocol.
    * @task uri
    */
   private function shouldUseHTTP() {
     if ($this->isHosted()) {
       return false;
     }
 
     $protocol = $this->getRemoteProtocol();
     return ($protocol == 'http' || $protocol == 'https');
   }
 
 
   /**
    * Determine if we should connect to the remote using SVN flags and
    * credentials.
    *
    * @return bool True to use the SVN protocol.
    * @task uri
    */
   private function shouldUseSVNProtocol() {
     if ($this->isHosted()) {
       return false;
     }
 
     $protocol = $this->getRemoteProtocol();
     return ($protocol == 'svn');
   }
 
 
   /**
    * Determine if a protocol is SSH or SSH-like.
    *
-   * @param string A protocol string, like "http" or "ssh".
+   * @param string $protocol A protocol string, like "http" or "ssh".
    * @return bool True if the protocol is SSH-like.
    * @task uri
    */
   private function isSSHProtocol($protocol) {
     return ($protocol == 'ssh' || $protocol == 'svn+ssh');
   }
 
   public function delete() {
     $this->openTransaction();
 
       $paths = id(new PhabricatorOwnersPath())
         ->loadAllWhere('repositoryPHID = %s', $this->getPHID());
       foreach ($paths as $path) {
         $path->delete();
       }
 
       queryfx(
         $this->establishConnection('w'),
         'DELETE FROM %T WHERE repositoryPHID = %s',
         id(new PhabricatorRepositorySymbol())->getTableName(),
         $this->getPHID());
 
       $commits = id(new PhabricatorRepositoryCommit())
         ->loadAllWhere('repositoryID = %d', $this->getID());
       foreach ($commits as $commit) {
         // note PhabricatorRepositoryAuditRequests and
         // PhabricatorRepositoryCommitData are deleted here too.
         $commit->delete();
       }
 
       $uris = id(new PhabricatorRepositoryURI())
         ->loadAllWhere('repositoryPHID = %s', $this->getPHID());
       foreach ($uris as $uri) {
         $uri->delete();
       }
 
       $ref_cursors = id(new PhabricatorRepositoryRefCursor())
         ->loadAllWhere('repositoryPHID = %s', $this->getPHID());
       foreach ($ref_cursors as $cursor) {
         $cursor->delete();
       }
 
       $conn_w = $this->establishConnection('w');
 
       queryfx(
         $conn_w,
         'DELETE FROM %T WHERE repositoryID = %d',
         self::TABLE_FILESYSTEM,
         $this->getID());
 
       queryfx(
         $conn_w,
         'DELETE FROM %T WHERE repositoryID = %d',
         self::TABLE_PATHCHANGE,
         $this->getID());
 
       queryfx(
         $conn_w,
         'DELETE FROM %T WHERE repositoryID = %d',
         self::TABLE_SUMMARY,
         $this->getID());
 
       $result = parent::delete();
 
     $this->saveTransaction();
     return $result;
   }
 
   public function isGit() {
     $vcs = $this->getVersionControlSystem();
     return ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_GIT);
   }
 
   public function isSVN() {
     $vcs = $this->getVersionControlSystem();
     return ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_SVN);
   }
 
   public function isHg() {
     $vcs = $this->getVersionControlSystem();
     return ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL);
   }
 
   public function isHosted() {
     return (bool)$this->getDetail('hosting-enabled', false);
   }
 
   public function setHosted($enabled) {
     return $this->setDetail('hosting-enabled', $enabled);
   }
 
   public function canServeProtocol(
     $protocol,
     $write,
     $is_intracluster = false) {
 
     // See T13192. If a repository is inactive, don't serve it to users. We
     // still synchronize it within the cluster and serve it to other repository
     // nodes.
     if (!$is_intracluster) {
       if (!$this->isTracked()) {
         return false;
       }
     }
 
     $clone_uris = $this->getCloneURIs();
     foreach ($clone_uris as $uri) {
       if ($uri->getBuiltinProtocol() !== $protocol) {
         continue;
       }
 
       $io_type = $uri->getEffectiveIoType();
       if ($io_type == PhabricatorRepositoryURI::IO_READWRITE) {
         return true;
       }
 
       if (!$write) {
         if ($io_type == PhabricatorRepositoryURI::IO_READ) {
           return true;
         }
       }
     }
 
     if ($write) {
       if ($this->isReadOnly()) {
         return false;
       }
     }
 
     return false;
   }
 
   public function hasLocalWorkingCopy() {
     try {
       self::assertLocalExists();
       return true;
     } catch (Exception $ex) {
       return false;
     }
   }
 
   /**
    * Raise more useful errors when there are basic filesystem problems.
    */
   private function assertLocalExists() {
     if (!$this->usesLocalWorkingCopy()) {
       return;
     }
 
     $local = $this->getLocalPath();
     Filesystem::assertExists($local);
     Filesystem::assertIsDirectory($local);
     Filesystem::assertReadable($local);
   }
 
   /**
    * Determine if the working copy is bare or not. In Git, this corresponds
    * to `--bare`. In Mercurial, `--noupdate`.
    */
   public function isWorkingCopyBare() {
     switch ($this->getVersionControlSystem()) {
       case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
       case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
         return false;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
         $local = $this->getLocalPath();
         if (Filesystem::pathExists($local.'/.git')) {
           return false;
         } else {
           return true;
         }
     }
   }
 
   public function usesLocalWorkingCopy() {
     switch ($this->getVersionControlSystem()) {
       case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
         return $this->isHosted();
       case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
       case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
         return true;
     }
   }
 
   public function getHookDirectories() {
     $directories = array();
     if (!$this->isHosted()) {
       return $directories;
     }
 
     $root = $this->getLocalPath();
 
     switch ($this->getVersionControlSystem()) {
       case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
         if ($this->isWorkingCopyBare()) {
           $directories[] = $root.'/hooks/pre-receive-phabricator.d/';
         } else {
           $directories[] = $root.'/.git/hooks/pre-receive-phabricator.d/';
         }
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
         $directories[] = $root.'/hooks/pre-commit-phabricator.d/';
         break;
       case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
         // NOTE: We don't support custom Mercurial hooks for now because they're
         // messy and we can't easily just drop a `hooks.d/` directory next to
         // the hooks.
         break;
     }
 
     return $directories;
   }
 
   public function canDestroyWorkingCopy() {
     if ($this->isHosted()) {
       // Never destroy hosted working copies.
       return false;
     }
 
     $default_path = PhabricatorEnv::getEnvConfig(
       'repository.default-local-path');
     return Filesystem::isDescendant($this->getLocalPath(), $default_path);
   }
 
   public function canUsePathTree() {
     return !$this->isSVN();
   }
 
   public function canUseGitLFS() {
     if (!$this->isGit()) {
       return false;
     }
 
     if (!$this->isHosted()) {
       return false;
     }
 
     if (!PhabricatorEnv::getEnvConfig('diffusion.allow-git-lfs')) {
       return false;
     }
 
     return true;
   }
 
   public function getGitLFSURI($path = null) {
     if (!$this->canUseGitLFS()) {
       throw new Exception(
         pht(
           'This repository does not support Git LFS, so Git LFS URIs can '.
           'not be generated for it.'));
     }
 
     $uri = $this->getRawHTTPCloneURIObject();
     $uri = (string)$uri;
     $uri = $uri.'/'.$path;
 
     return $uri;
   }
 
   public function canMirror() {
     if ($this->isGit() || $this->isHg()) {
       return true;
     }
 
     return false;
   }
 
   public function canAllowDangerousChanges() {
     if (!$this->isHosted()) {
       return false;
     }
 
     // In Git and Mercurial, ref deletions and rewrites are dangerous.
     // In Subversion, editing revprops is dangerous.
 
     return true;
   }
 
   public function shouldAllowDangerousChanges() {
     return (bool)$this->getDetail('allow-dangerous-changes');
   }
 
   public function canAllowEnormousChanges() {
     if (!$this->isHosted()) {
       return false;
     }
 
     return true;
   }
 
   public function shouldAllowEnormousChanges() {
     return (bool)$this->getDetail('allow-enormous-changes');
   }
 
   public function writeStatusMessage(
     $status_type,
     $status_code,
     array $parameters = array()) {
 
     $table = new PhabricatorRepositoryStatusMessage();
     $conn_w = $table->establishConnection('w');
     $table_name = $table->getTableName();
 
     if ($status_code === null) {
       queryfx(
         $conn_w,
         'DELETE FROM %T WHERE repositoryID = %d AND statusType = %s',
         $table_name,
         $this->getID(),
         $status_type);
     } else {
       // If the existing message has the same code (e.g., we just hit an
       // error and also previously hit an error) we increment the message
       // count. This allows us to determine how many times in a row we've
       // run into an error.
 
       // NOTE: The assignments in "ON DUPLICATE KEY UPDATE" are evaluated
       // in order, so the "messageCount" assignment must occur before the
       // "statusCode" assignment. See T11705.
 
       queryfx(
         $conn_w,
         'INSERT INTO %T
           (repositoryID, statusType, statusCode, parameters, epoch,
             messageCount)
           VALUES (%d, %s, %s, %s, %d, %d)
           ON DUPLICATE KEY UPDATE
             messageCount =
               IF(
                 statusCode = VALUES(statusCode),
                 messageCount + VALUES(messageCount),
                 VALUES(messageCount)),
             statusCode = VALUES(statusCode),
             parameters = VALUES(parameters),
             epoch = VALUES(epoch)',
         $table_name,
         $this->getID(),
         $status_type,
         $status_code,
         json_encode($parameters),
         time(),
         1);
     }
 
     return $this;
   }
 
   public static function assertValidRemoteURI($uri) {
     if (trim($uri) != $uri) {
       throw new Exception(
         pht('The remote URI has leading or trailing whitespace.'));
     }
 
     $uri_object = new PhutilURI($uri);
     $protocol = $uri_object->getProtocol();
 
     // Catch confusion between Git/SCP-style URIs and normal URIs. See T3619
     // for discussion. This is usually a user adding "ssh://" to an implicit
     // SSH Git URI.
     if ($protocol == 'ssh') {
       if (preg_match('(^[^:@]+://[^/:]+:[^\d])', $uri)) {
         throw new Exception(
           pht(
             "The remote URI is not formatted correctly. Remote URIs ".
             "with an explicit protocol should be in the form ".
             "'%s', not '%s'. The '%s' syntax is only valid in SCP-style URIs.",
             'proto://domain/path',
             'proto://domain:/path',
             ':/path'));
       }
     }
 
     switch ($protocol) {
       case 'ssh':
       case 'http':
       case 'https':
       case 'git':
       case 'svn':
       case 'svn+ssh':
         break;
       default:
         // NOTE: We're explicitly rejecting 'file://' because it can be
         // used to clone from the working copy of another repository on disk
         // that you don't normally have permission to access.
 
         throw new Exception(
           pht(
             'The URI protocol is unrecognized. It should begin with '.
             '"%s", "%s", "%s", "%s", "%s", "%s", or be in the form "%s".',
             'ssh://',
             'http://',
             'https://',
             'git://',
             'svn://',
             'svn+ssh://',
             'git@domain.com:path'));
     }
 
     return true;
   }
 
 
   /**
    * Load the pull frequency for this repository, based on the time since the
    * last activity.
    *
    * We pull rarely used repositories less frequently. This finds the most
    * recent commit which is older than the current time (which prevents us from
    * spinning on repositories with a silly commit post-dated to some time in
    * 2037). We adjust the pull frequency based on when the most recent commit
    * occurred.
    *
-   * @param   int   The minimum update interval to use, in seconds.
+   * @param   int? $minimum The minimum update interval to use, in seconds.
    * @return  int   Repository update interval, in seconds.
    */
   public function loadUpdateInterval($minimum = 15) {
     // First, check if we've hit errors recently. If we have, wait one period
     // for each consecutive error. Normally, this corresponds to a backoff of
     // 15s, 30s, 45s, etc.
 
     $message_table = new PhabricatorRepositoryStatusMessage();
     $conn = $message_table->establishConnection('r');
     $error_count = queryfx_one(
       $conn,
       'SELECT MAX(messageCount) error_count FROM %T
         WHERE repositoryID = %d
         AND statusType IN (%Ls)
         AND statusCode IN (%Ls)',
       $message_table->getTableName(),
       $this->getID(),
       array(
         PhabricatorRepositoryStatusMessage::TYPE_INIT,
         PhabricatorRepositoryStatusMessage::TYPE_FETCH,
       ),
       array(
         PhabricatorRepositoryStatusMessage::CODE_ERROR,
       ));
 
     $error_count = (int)$error_count['error_count'];
     if ($error_count > 0) {
       return (int)($minimum * $error_count);
     }
 
     // If a repository is still importing, always pull it as frequently as
     // possible. This prevents us from hanging for a long time at 99.9% when
     // importing an inactive repository.
     if ($this->isImporting()) {
       return $minimum;
     }
 
     $window_start = (PhabricatorTime::getNow() + $minimum);
 
     $table = id(new PhabricatorRepositoryCommit());
     $last_commit = queryfx_one(
       $table->establishConnection('r'),
       'SELECT epoch FROM %T
         WHERE repositoryID = %d AND epoch <= %d
         ORDER BY epoch DESC LIMIT 1',
       $table->getTableName(),
       $this->getID(),
       $window_start);
     if ($last_commit) {
       $time_since_commit = ($window_start - $last_commit['epoch']);
     } else {
       // If the repository has no commits, treat the creation date as
       // though it were the date of the last commit. This makes empty
       // repositories update quickly at first but slow down over time
       // if they don't see any activity.
       $time_since_commit = ($window_start - $this->getDateCreated());
     }
 
     $last_few_days = phutil_units('3 days in seconds');
 
     if ($time_since_commit <= $last_few_days) {
       // For repositories with activity in the recent past, we wait one
       // extra second for every 10 minutes since the last commit. This
       // shorter backoff is intended to handle weekends and other short
       // breaks from development.
       $smart_wait = ($time_since_commit / 600);
     } else {
       // For repositories without recent activity, we wait one extra second
       // for every 4 minutes since the last commit. This longer backoff
       // handles rarely used repositories, up to the maximum.
       $smart_wait = ($time_since_commit / 240);
     }
 
     // We'll never wait more than 6 hours to pull a repository.
     $longest_wait = phutil_units('6 hours in seconds');
     $smart_wait = min($smart_wait, $longest_wait);
     $smart_wait = max($minimum, $smart_wait);
 
     return (int)$smart_wait;
   }
 
 
   /**
    * Time limit for cloning or copying this repository.
    *
    * This limit is used to timeout operations like `git clone` or `git fetch`
    * when doing intracluster synchronization, building working copies, etc.
    *
    * @return int Maximum number of seconds to spend copying this repository.
    */
   public function getCopyTimeLimit() {
     return $this->getDetail('limit.copy');
   }
 
   public function setCopyTimeLimit($limit) {
     return $this->setDetail('limit.copy', $limit);
   }
 
   public function getDefaultCopyTimeLimit() {
     return phutil_units('15 minutes in seconds');
   }
 
   public function getEffectiveCopyTimeLimit() {
     $limit = $this->getCopyTimeLimit();
     if ($limit) {
       return $limit;
     }
 
     return $this->getDefaultCopyTimeLimit();
   }
 
   public function getFilesizeLimit() {
     return $this->getDetail('limit.filesize');
   }
 
   public function setFilesizeLimit($limit) {
     return $this->setDetail('limit.filesize', $limit);
   }
 
   public function getTouchLimit() {
     return $this->getDetail('limit.touch');
   }
 
   public function setTouchLimit($limit) {
     return $this->setDetail('limit.touch', $limit);
   }
 
   /**
    * Retrieve the service URI for the device hosting this repository.
    *
    * See @{method:newConduitClient} for a general discussion of interacting
    * with repository services. This method provides lower-level resolution of
    * services, returning raw URIs.
    *
-   * @param PhabricatorUser Viewing user.
-   * @param map<string, wild> Constraints on selectable services.
+   * @param PhabricatorUser $viewer Viewing user.
+   * @param map<string, wild> $options Constraints on selectable services.
    * @return string|null URI, or `null` for local repositories.
    */
   public function getAlmanacServiceURI(
     PhabricatorUser $viewer,
     array $options) {
 
     $refs = $this->getAlmanacServiceRefs($viewer, $options);
 
     if (!$refs) {
       return null;
     }
 
     $ref = head($refs);
     return $ref->getURI();
   }
 
   public function getAlmanacServiceRefs(
     PhabricatorUser $viewer,
     array $options) {
 
     PhutilTypeSpec::checkMap(
       $options,
       array(
         'neverProxy' => 'bool',
         'protocols' => 'list<string>',
         'writable' => 'optional bool',
       ));
 
     $never_proxy = $options['neverProxy'];
     $protocols = $options['protocols'];
     $writable = idx($options, 'writable', false);
 
     $cache_key = $this->getAlmanacServiceCacheKey();
     if (!$cache_key) {
       return array();
     }
 
     $cache = PhabricatorCaches::getMutableStructureCache();
     $uris = $cache->getKey($cache_key, false);
 
     // If we haven't built the cache yet, build it now.
     if ($uris === false) {
       $uris = $this->buildAlmanacServiceURIs();
       $cache->setKey($cache_key, $uris);
     }
 
     if ($uris === null) {
       return array();
     }
 
     $local_device = AlmanacKeys::getDeviceID();
     if ($never_proxy && !$local_device) {
       throw new Exception(
         pht(
           'Unable to handle proxied service request. This device is not '.
           'registered, so it can not identify local services. Register '.
           'this device before sending requests here.'));
     }
 
     $protocol_map = array_fuse($protocols);
 
     $results = array();
     foreach ($uris as $uri) {
       // If we're never proxying this and it's locally satisfiable, return
       // `null` to tell the caller to handle it locally. If we're allowed to
       // proxy, we skip this check and may proxy the request to ourselves.
       // (That proxied request will end up here with proxying forbidden,
       // return `null`, and then the request will actually run.)
 
       if ($local_device && $never_proxy) {
         if ($uri['device'] == $local_device) {
           return array();
         }
       }
 
       if (isset($protocol_map[$uri['protocol']])) {
         $results[] = $uri;
       }
     }
 
     if (!$results) {
       throw new Exception(
         pht(
           'The Almanac service for this repository is not bound to any '.
           'interfaces which support the required protocols (%s).',
           implode(', ', $protocols)));
     }
 
     if ($never_proxy) {
       // See PHI1030. This error can arise from various device name/address
       // mismatches which are hard to detect, so try to provide as much
       // information as we can.
 
       if ($writable) {
         $request_type = pht('(This is a write request.)');
       } else {
         $request_type = pht('(This is a read request.)');
       }
 
       throw new Exception(
         pht(
           'This repository request (for repository "%s") has been '.
           'incorrectly routed to a cluster host (with device name "%s", '.
           'and hostname "%s") which can not serve the request.'.
           "\n\n".
           'The Almanac device address for the correct device may improperly '.
           'point at this host, or the "device.id" configuration file on '.
           'this host may be incorrect.'.
           "\n\n".
           'Requests routed within the cluster are always '.
           'expected to be sent to a node which can serve the request. To '.
           'prevent loops, this request will not be proxied again.'.
           "\n\n".
           "%s",
           $this->getDisplayName(),
           $local_device,
           php_uname('n'),
           $request_type));
     }
 
     if (count($results) > 1) {
       if (!$this->supportsSynchronization()) {
         throw new Exception(
           pht(
             'Repository "%s" is bound to multiple active repository hosts, '.
             'but this repository does not support cluster synchronization. '.
             'Declusterize this repository or move it to a service with only '.
             'one host.',
             $this->getDisplayName()));
       }
     }
 
     $refs = array();
     foreach ($results as $result) {
       $refs[] = DiffusionServiceRef::newFromDictionary($result);
     }
 
     // If we require a writable device, remove URIs which aren't writable.
     if ($writable) {
       foreach ($refs as $key => $ref) {
         if (!$ref->isWritable()) {
           unset($refs[$key]);
         }
       }
 
       if (!$refs) {
         throw new Exception(
           pht(
             'This repository ("%s") is not writable with the given '.
             'protocols (%s). The Almanac service for this repository has no '.
             'writable bindings that support these protocols.',
             $this->getDisplayName(),
             implode(', ', $protocols)));
       }
     }
 
     if ($writable) {
       $refs = $this->sortWritableAlmanacServiceRefs($refs);
     } else {
       $refs = $this->sortReadableAlmanacServiceRefs($refs);
     }
 
     return array_values($refs);
   }
 
   private function sortReadableAlmanacServiceRefs(array $refs) {
     assert_instances_of($refs, 'DiffusionServiceRef');
     shuffle($refs);
     return $refs;
   }
 
   private function sortWritableAlmanacServiceRefs(array $refs) {
     assert_instances_of($refs, 'DiffusionServiceRef');
 
     // See T13109 for discussion of how this method routes requests.
 
     // In the absence of other rules, we'll send traffic to devices randomly.
     // We also want to select randomly among nodes which are equally good
     // candidates to receive the write, and accomplish that by shuffling the
     // list up front.
     shuffle($refs);
 
     $order = array();
 
     // If some device is currently holding the write lock, send all requests
     // to that device. We're trying to queue writes on a single device so they
     // do not need to wait for read synchronization after earlier writes
     // complete.
     $writer = PhabricatorRepositoryWorkingCopyVersion::loadWriter(
       $this->getPHID());
     if ($writer) {
       $device_phid = $writer->getWriteProperty('devicePHID');
       foreach ($refs as $key => $ref) {
         if ($ref->getDevicePHID() === $device_phid) {
           $order[] = $key;
         }
       }
     }
 
     // If no device is currently holding the write lock, try to send requests
     // to a device which is already up to date and will not need to synchronize
     // before it can accept the write.
     $versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions(
       $this->getPHID());
     if ($versions) {
       $max_version = (int)max(mpull($versions, 'getRepositoryVersion'));
 
       $max_devices = array();
       foreach ($versions as $version) {
         if ($version->getRepositoryVersion() == $max_version) {
           $max_devices[] = $version->getDevicePHID();
         }
       }
       $max_devices = array_fuse($max_devices);
 
       foreach ($refs as $key => $ref) {
         if (isset($max_devices[$ref->getDevicePHID()])) {
           $order[] = $key;
         }
       }
     }
 
     // Reorder the results, putting any we've selected as preferred targets for
     // the write at the head of the list.
     $refs = array_select_keys($refs, $order) + $refs;
 
     return $refs;
   }
 
   public function supportsSynchronization() {
     // TODO: For now, this is only supported for Git.
     if (!$this->isGit()) {
       return false;
     }
 
     return true;
   }
 
 
   public function supportsRefs() {
     if ($this->isSVN()) {
       return false;
     }
 
     return true;
   }
 
   public function getAlmanacServiceCacheKey() {
     $service_phid = $this->getAlmanacServicePHID();
     if (!$service_phid) {
       return null;
     }
 
     $repository_phid = $this->getPHID();
 
     $parts = array(
       "repo({$repository_phid})",
       "serv({$service_phid})",
       'v4',
     );
 
     return implode('.', $parts);
   }
 
   private function buildAlmanacServiceURIs() {
     $service = $this->loadAlmanacService();
     if (!$service) {
       return null;
     }
 
     $bindings = $service->getActiveBindings();
     if (!$bindings) {
       throw new Exception(
         pht(
           'The Almanac service for this repository is not bound to any '.
           'active interfaces.'));
     }
 
     $uris = array();
     foreach ($bindings as $binding) {
       $iface = $binding->getInterface();
 
       $uri = $this->getClusterRepositoryURIFromBinding($binding);
       $protocol = $uri->getProtocol();
       $device_name = $iface->getDevice()->getName();
       $device_phid = $iface->getDevice()->getPHID();
 
       $uris[] = array(
         'protocol' => $protocol,
         'uri' => (string)$uri,
         'device' => $device_name,
         'writable' => (bool)$binding->getAlmanacPropertyValue('writable'),
         'devicePHID' => $device_phid,
       );
     }
 
     return $uris;
   }
 
   /**
    * Build a new Conduit client in order to make a service call to this
    * repository.
    *
    * If the repository is hosted locally, this method may return `null`. The
    * caller should use `ConduitCall` or other local logic to complete the
    * request.
    *
    * By default, we will return a @{class:ConduitClient} for any repository with
    * a service, even if that service is on the current device.
    *
    * We do this because this configuration does not make very much sense in a
    * production context, but is very common in a test/development context
    * (where the developer's machine is both the web host and the repository
    * service). By proxying in development, we get more consistent behavior
    * between development and production, and don't have a major untested
    * codepath.
    *
    * The `$never_proxy` parameter can be used to prevent this local proxying.
    * If the flag is passed:
    *
    *   - The method will return `null` (implying a local service call)
    *     if the repository service is hosted on the current device.
    *   - The method will throw if it would need to return a client.
    *
    * This is used to prevent loops in Conduit: the first request will proxy,
    * even in development, but the second request will be identified as a
    * cluster request and forced not to proxy.
    *
    * For lower-level service resolution, see @{method:getAlmanacServiceURI}.
    *
-   * @param PhabricatorUser Viewing user.
-   * @param bool `true` to throw if a client would be returned.
+   * @param PhabricatorUser $viewer Viewing user.
+   * @param bool? $never_proxy `true` to throw if a client would be returned.
    * @return ConduitClient|null Client, or `null` for local repositories.
    */
   public function newConduitClient(
     PhabricatorUser $viewer,
     $never_proxy = false) {
 
     $uri = $this->getAlmanacServiceURI(
       $viewer,
       array(
         'neverProxy' => $never_proxy,
         'protocols' => array(
           'http',
           'https',
         ),
 
         // At least today, no Conduit call can ever write to a repository,
         // so it's fine to send anything to a read-only node.
         'writable' => false,
       ));
     if ($uri === null) {
       return null;
     }
 
     $domain = id(new PhutilURI(PhabricatorEnv::getURI('/')))->getDomain();
 
     $client = id(new ConduitClient($uri))
       ->setHost($domain);
 
     if ($viewer->isOmnipotent()) {
       // If the caller is the omnipotent user (normally, a daemon), we will
       // sign the request with this host's asymmetric keypair.
 
       $public_path = AlmanacKeys::getKeyPath('device.pub');
       try {
         $public_key = Filesystem::readFile($public_path);
       } catch (Exception $ex) {
         throw new PhutilAggregateException(
           pht(
             'Unable to read device public key while attempting to make '.
             'authenticated method call within the cluster. '.
             'Use `%s` to register keys for this device. Exception: %s',
             'bin/almanac register',
             $ex->getMessage()),
           array($ex));
       }
 
       $private_path = AlmanacKeys::getKeyPath('device.key');
       try {
         $private_key = Filesystem::readFile($private_path);
         $private_key = new PhutilOpaqueEnvelope($private_key);
       } catch (Exception $ex) {
         throw new PhutilAggregateException(
           pht(
             'Unable to read device private key while attempting to make '.
             'authenticated method call within the cluster. '.
             'Use `%s` to register keys for this device. Exception: %s',
             'bin/almanac register',
             $ex->getMessage()),
           array($ex));
       }
 
       $client->setSigningKeys($public_key, $private_key);
     } else {
       // If the caller is a normal user, we generate or retrieve a cluster
       // API token.
 
       $token = PhabricatorConduitToken::loadClusterTokenForUser($viewer);
       if ($token) {
         $client->setConduitToken($token->getToken());
       }
     }
 
     return $client;
   }
 
   public function newConduitClientForRequest(ConduitAPIRequest $request) {
     // Figure out whether we're going to handle this request on this device,
     // or proxy it to another node in the cluster.
 
     // If this is a cluster request and we need to proxy, we'll explode here
     // to prevent infinite recursion.
 
     $viewer = $request->getViewer();
     $is_cluster_request = $request->getIsClusterRequest();
 
     $client = $this->newConduitClient(
       $viewer,
       $is_cluster_request);
 
     return $client;
   }
 
   public function newConduitFuture(
     PhabricatorUser $viewer,
     $method,
     array $params,
     $never_proxy = false) {
 
     $client = $this->newConduitClient(
       $viewer,
       $never_proxy);
 
     if (!$client) {
       $conduit_call = id(new ConduitCall($method, $params))
         ->setUser($viewer);
       $future = new MethodCallFuture($conduit_call, 'execute');
     } else {
       $future = $client->callMethod($method, $params);
     }
 
     return $future;
   }
 
   public function getPassthroughEnvironmentalVariables() {
     $env = $_ENV;
 
     if ($this->isGit()) {
       // $_ENV does not populate in CLI contexts if "E" is missing from
       // "variables_order" in PHP config. Currently, we do not require this
       // to be configured. Since it may not be, explicitly bring expected Git
       // environmental variables into scope. This list is not exhaustive, but
       // only lists variables with a known impact on commit hook behavior.
 
       // This can be removed if we later require "E" in "variables_order".
 
       $git_env = array(
         'GIT_OBJECT_DIRECTORY',
         'GIT_ALTERNATE_OBJECT_DIRECTORIES',
         'GIT_QUARANTINE_PATH',
       );
       foreach ($git_env as $key) {
         $value = getenv($key);
         if (strlen($value)) {
           $env[$key] = $value;
         }
       }
 
       $key = 'GIT_PUSH_OPTION_COUNT';
       $git_count = getenv($key);
       if (strlen($git_count)) {
         $git_count = (int)$git_count;
         $env[$key] = $git_count;
         for ($ii = 0; $ii < $git_count; $ii++) {
           $key = 'GIT_PUSH_OPTION_'.$ii;
           $env[$key] = getenv($key);
         }
       }
     }
 
     $result = array();
     foreach ($env as $key => $value) {
       // In Git, pass anything matching "GIT_*" though. Some of these variables
       // need to be preserved to allow `git` operations to work properly when
       // running from commit hooks.
       if ($this->isGit()) {
         if (preg_match('/^GIT_/', $key)) {
           $result[$key] = $value;
         }
       }
     }
 
     return $result;
   }
 
   public function supportsBranchComparison() {
     return $this->isGit();
   }
 
   public function isReadOnly() {
     return (bool)$this->getDetail('read-only');
   }
 
   public function setReadOnly($read_only) {
     return $this->setDetail('read-only', $read_only);
   }
 
   public function getReadOnlyMessage() {
     return $this->getDetail('read-only-message');
   }
 
   public function setReadOnlyMessage($message) {
     return $this->setDetail('read-only-message', $message);
   }
 
   public function getReadOnlyMessageForDisplay() {
     $parts = array();
     $parts[] = pht(
       'This repository is currently in read-only maintenance mode.');
 
     $message = $this->getReadOnlyMessage();
     if ($message !== null) {
       $parts[] = $message;
     }
 
     return implode("\n\n", $parts);
   }
 
 /* -(  Repository URIs  )---------------------------------------------------- */
 
 
   public function attachURIs(array $uris) {
     $custom_map = array();
     foreach ($uris as $key => $uri) {
       $builtin_key = $uri->getRepositoryURIBuiltinKey();
       if ($builtin_key !== null) {
         $custom_map[$builtin_key] = $key;
       }
     }
 
     $builtin_uris = $this->newBuiltinURIs();
     $seen_builtins = array();
     foreach ($builtin_uris as $builtin_uri) {
       $builtin_key = $builtin_uri->getRepositoryURIBuiltinKey();
       $seen_builtins[$builtin_key] = true;
 
       // If this builtin URI is disabled, don't attach it and remove the
       // persisted version if it exists.
       if ($builtin_uri->getIsDisabled()) {
         if (isset($custom_map[$builtin_key])) {
           unset($uris[$custom_map[$builtin_key]]);
         }
         continue;
       }
 
       // If the URI exists, make sure it's marked as not being disabled.
       if (isset($custom_map[$builtin_key])) {
         $uris[$custom_map[$builtin_key]]->setIsDisabled(false);
       }
     }
 
     // Remove any builtins which no longer exist.
     foreach ($custom_map as $builtin_key => $key) {
       if (empty($seen_builtins[$builtin_key])) {
         unset($uris[$key]);
       }
     }
 
     $this->uris = $uris;
 
     return $this;
   }
 
   public function getURIs() {
     return $this->assertAttached($this->uris);
   }
 
   public function getCloneURIs() {
     $uris = $this->getURIs();
 
     $clone = array();
     foreach ($uris as $uri) {
       if (!$uri->isBuiltin()) {
         continue;
       }
 
       if ($uri->getIsDisabled()) {
         continue;
       }
 
       $io_type = $uri->getEffectiveIoType();
       $is_clone =
         ($io_type == PhabricatorRepositoryURI::IO_READ) ||
         ($io_type == PhabricatorRepositoryURI::IO_READWRITE);
 
       if (!$is_clone) {
         continue;
       }
 
       $clone[] = $uri;
     }
 
     $clone = msort($clone, 'getURIScore');
     $clone = array_reverse($clone);
 
     return $clone;
   }
 
 
   public function newBuiltinURIs() {
     $has_callsign = ($this->getCallsign() !== null);
     $has_shortname = ($this->getRepositorySlug() !== null);
 
     $identifier_map = array(
       PhabricatorRepositoryURI::BUILTIN_IDENTIFIER_CALLSIGN => $has_callsign,
       PhabricatorRepositoryURI::BUILTIN_IDENTIFIER_SHORTNAME => $has_shortname,
       PhabricatorRepositoryURI::BUILTIN_IDENTIFIER_ID => true,
     );
 
     // If the view policy of the repository is public, support anonymous HTTP
     // even if authenticated HTTP is not supported.
     if ($this->getViewPolicy() === PhabricatorPolicies::POLICY_PUBLIC) {
       $allow_http = true;
     } else {
       $allow_http = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth');
     }
 
     $base_uri = PhabricatorEnv::getURI('/');
     $base_uri = new PhutilURI($base_uri);
     $has_https = ($base_uri->getProtocol() == 'https');
     $has_https = ($has_https && $allow_http);
 
     $has_http = !PhabricatorEnv::getEnvConfig('security.require-https');
     $has_http = ($has_http && $allow_http);
 
     // HTTP is not supported for Subversion.
     if ($this->isSVN()) {
       $has_http = false;
       $has_https = false;
     }
 
     $phd_user = PhabricatorEnv::getEnvConfig('phd.user');
     $has_ssh = phutil_nonempty_string($phd_user);
 
     $protocol_map = array(
       PhabricatorRepositoryURI::BUILTIN_PROTOCOL_SSH => $has_ssh,
       PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTPS => $has_https,
       PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTP => $has_http,
     );
 
     $uris = array();
     foreach ($protocol_map as $protocol => $proto_supported) {
       foreach ($identifier_map as $identifier => $id_supported) {
         // This is just a dummy value because it can't be empty; we'll force
         // it to a proper value when using it in the UI.
         $builtin_uri = "{$protocol}://{$identifier}";
         $uris[] = PhabricatorRepositoryURI::initializeNewURI()
           ->setRepositoryPHID($this->getPHID())
           ->attachRepository($this)
           ->setBuiltinProtocol($protocol)
           ->setBuiltinIdentifier($identifier)
           ->setURI($builtin_uri)
           ->setIsDisabled((int)(!$proto_supported || !$id_supported));
       }
     }
 
     return $uris;
   }
 
 
   public function getClusterRepositoryURIFromBinding(
     AlmanacBinding $binding) {
     $protocol = $binding->getAlmanacPropertyValue('protocol');
     if ($protocol === null) {
       $protocol = 'https';
     }
 
     $iface = $binding->getInterface();
     $address = $iface->renderDisplayAddress();
 
     $path = $this->getURI();
 
     return id(new PhutilURI("{$protocol}://{$address}"))
       ->setPath($path);
   }
 
   public function loadAlmanacService() {
     $service_phid = $this->getAlmanacServicePHID();
     if (!$service_phid) {
       // No service, so this is a local repository.
       return null;
     }
 
     $service = id(new AlmanacServiceQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withPHIDs(array($service_phid))
       ->needActiveBindings(true)
       ->needProperties(true)
       ->executeOne();
     if (!$service) {
       throw new Exception(
         pht(
           'The Almanac service for this repository is invalid or could not '.
           'be loaded.'));
     }
 
     $service_type = $service->getServiceImplementation();
     if (!($service_type instanceof AlmanacClusterRepositoryServiceType)) {
       throw new Exception(
         pht(
           'The Almanac service for this repository does not have the correct '.
           'service type.'));
     }
 
     return $service;
   }
 
   public function markImporting() {
     $this->openTransaction();
       $this->beginReadLocking();
         $repository = $this->reload();
         $repository->setDetail('importing', true);
         $repository->save();
       $this->endReadLocking();
     $this->saveTransaction();
 
     return $repository;
   }
 
 
 /* -(  Symbols  )-------------------------------------------------------------*/
 
   public function getSymbolSources() {
     return $this->getDetail('symbol-sources', array());
   }
 
   public function getSymbolLanguages() {
     return $this->getDetail('symbol-languages', array());
   }
 
 
 /* -(  Staging  )------------------------------------------------------------ */
 
 
   public function supportsStaging() {
     return $this->isGit();
   }
 
 
   public function getStagingURI() {
     if (!$this->supportsStaging()) {
       return null;
     }
     return $this->getDetail('staging-uri', null);
   }
 
 
 /* -(  Automation  )--------------------------------------------------------- */
 
 
   public function supportsAutomation() {
     return $this->isGit();
   }
 
   public function canPerformAutomation() {
     if (!$this->supportsAutomation()) {
       return false;
     }
 
     if (!$this->getAutomationBlueprintPHIDs()) {
       return false;
     }
 
     return true;
   }
 
   public function getAutomationBlueprintPHIDs() {
     if (!$this->supportsAutomation()) {
       return array();
     }
     return $this->getDetail('automation.blueprintPHIDs', array());
   }
 
 
 /* -(  PhabricatorApplicationTransactionInterface  )------------------------- */
 
 
   public function getApplicationTransactionEditor() {
     return new PhabricatorRepositoryEditor();
   }
 
   public function getApplicationTransactionTemplate() {
     return new PhabricatorRepositoryTransaction();
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
       DiffusionPushCapability::CAPABILITY,
     );
   }
 
   public function getPolicy($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         return $this->getViewPolicy();
       case PhabricatorPolicyCapability::CAN_EDIT:
         return $this->getEditPolicy();
       case DiffusionPushCapability::CAPABILITY:
         return $this->getPushPolicy();
     }
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $user) {
     return false;
   }
 
 
 /* -(  PhabricatorMarkupInterface  )----------------------------------------- */
 
 
   public function getMarkupFieldKey($field) {
     $hash = PhabricatorHash::digestForIndex($this->getMarkupText($field));
     return "repo:{$hash}";
   }
 
   public function newMarkupEngine($field) {
     return PhabricatorMarkupEngine::newMarkupEngine(array());
   }
 
   public function getMarkupText($field) {
     return $this->getDetail('description');
   }
 
   public function didMarkupText(
     $field,
     $output,
     PhutilMarkupEngine $engine) {
     require_celerity_resource('phabricator-remarkup-css');
     return phutil_tag(
       'div',
       array(
         'class' => 'phabricator-remarkup',
       ),
       $output);
   }
 
   public function shouldUseMarkupCache($field) {
     return true;
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
 
     $phid = $this->getPHID();
 
     $this->openTransaction();
 
       $this->delete();
 
       PhabricatorRepositoryURIIndex::updateRepositoryURIs($phid, array());
 
       $books = id(new DivinerBookQuery())
         ->setViewer($engine->getViewer())
         ->withRepositoryPHIDs(array($phid))
         ->execute();
       foreach ($books as $book) {
         $engine->destroyObject($book);
       }
 
       $atoms = id(new DivinerAtomQuery())
         ->setViewer($engine->getViewer())
         ->withRepositoryPHIDs(array($phid))
         ->execute();
       foreach ($atoms as $atom) {
         $engine->destroyObject($atom);
       }
 
       $lfs_refs = id(new PhabricatorRepositoryGitLFSRefQuery())
         ->setViewer($engine->getViewer())
         ->withRepositoryPHIDs(array($phid))
         ->execute();
       foreach ($lfs_refs as $ref) {
         $engine->destroyObject($ref);
       }
 
     $this->saveTransaction();
   }
 
 
 /* -(  PhabricatorDestructibleCodexInterface  )------------------------------ */
 
 
   public function newDestructibleCodex() {
     return new PhabricatorRepositoryDestructibleCodex();
   }
 
 
 /* -(  PhabricatorSpacesInterface  )----------------------------------------- */
 
 
   public function getSpacePHID() {
     return $this->spacePHID;
   }
 
 /* -(  PhabricatorConduitResultInterface  )---------------------------------- */
 
 
   public function getFieldSpecificationsForConduit() {
     return array(
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('name')
         ->setType('string')
         ->setDescription(pht('The repository name.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('vcs')
         ->setType('string')
         ->setDescription(
           pht('The VCS this repository uses ("git", "hg" or "svn").')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('callsign')
         ->setType('string')
         ->setDescription(pht('The repository callsign, if it has one.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('shortName')
         ->setType('string')
         ->setDescription(pht('Unique short name, if the repository has one.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('status')
         ->setType('string')
         ->setDescription(pht('Active or inactive status.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('isImporting')
         ->setType('bool')
         ->setDescription(
           pht(
             'True if the repository is importing initial commits.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('almanacServicePHID')
         ->setType('phid?')
         ->setDescription(
           pht(
             'The Almanac Service that hosts this repository, if the '.
             'repository is clustered.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('refRules')
         ->setType('map<string, list<string>>')
         ->setDescription(
           pht(
             'The "Fetch" and "Permanent Ref" rules for this repository.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('defaultBranch')
         ->setType('string?')
         ->setDescription(pht('Default branch name.')),
       id(new PhabricatorConduitSearchFieldSpecification())
         ->setKey('description')
         ->setType('remarkup')
         ->setDescription(pht('Repository description.')),
     );
   }
 
   public function getFieldValuesForConduit() {
     $fetch_rules = $this->getFetchRules();
     $track_rules = $this->getTrackOnlyRules();
     $permanent_rules = $this->getPermanentRefRules();
 
     $fetch_rules = $this->getStringListForConduit($fetch_rules);
     $track_rules = $this->getStringListForConduit($track_rules);
     $permanent_rules = $this->getStringListForConduit($permanent_rules);
 
     $default_branch = $this->getDefaultBranch();
     if (!strlen($default_branch)) {
       $default_branch = null;
     }
 
     return array(
       'name' => $this->getName(),
       'vcs' => $this->getVersionControlSystem(),
       'callsign' => $this->getCallsign(),
       'shortName' => $this->getRepositorySlug(),
       'status' => $this->getStatus(),
       'isImporting' => (bool)$this->isImporting(),
       'almanacServicePHID' => $this->getAlmanacServicePHID(),
       'refRules' => array(
         'fetchRules' => $fetch_rules,
         'trackRules' => $track_rules,
         'permanentRefRules' => $permanent_rules,
       ),
       'defaultBranch' => $default_branch,
       'description' => array(
         'raw' => (string)$this->getDetail('description'),
       ),
     );
   }
 
   private function getStringListForConduit($list) {
     if (!is_array($list)) {
       $list = array();
     }
 
     foreach ($list as $key => $value) {
       $value = (string)$value;
       if (!strlen($value)) {
         unset($list[$key]);
       }
     }
 
     return array_values($list);
   }
 
   public function getConduitSearchAttachments() {
     return array(
       id(new DiffusionRepositoryURIsSearchEngineAttachment())
         ->setAttachmentKey('uris'),
       id(new DiffusionRepositoryMetricsSearchEngineAttachment())
         ->setAttachmentKey('metrics'),
     );
   }
 
 /* -(  PhabricatorFulltextInterface  )--------------------------------------- */
 
 
   public function newFulltextEngine() {
     return new PhabricatorRepositoryFulltextEngine();
   }
 
 
 /* -(  PhabricatorFerretInterface  )----------------------------------------- */
 
 
   public function newFerretEngine() {
     return new PhabricatorRepositoryFerretEngine();
   }
 
 }
diff --git a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php
index 489dd08065..1979ec092a 100644
--- a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php
+++ b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php
@@ -1,1671 +1,1672 @@
 <?php
 
 /**
  * Represents an abstract search engine for an application. It supports
  * creating and storing saved queries.
  *
  * @task construct  Constructing Engines
  * @task app        Applications
  * @task builtin    Builtin Queries
  * @task uri        Query URIs
  * @task dates      Date Filters
  * @task order      Result Ordering
  * @task read       Reading Utilities
  * @task exec       Paging and Executing Queries
  * @task render     Rendering Results
  * @task custom     Custom Fields
  */
 abstract class PhabricatorApplicationSearchEngine extends Phobject {
 
   private $application;
   private $viewer;
   private $errors = array();
   private $request;
   private $context;
   private $controller;
   private $namedQueries;
   private $navigationItems = array();
 
   const CONTEXT_LIST  = 'list';
   const CONTEXT_PANEL = 'panel';
 
   const BUCKET_NONE = 'none';
 
   public function setController(PhabricatorController $controller) {
     $this->controller = $controller;
     return $this;
   }
 
   public function getController() {
     return $this->controller;
   }
 
   public function buildResponse() {
     $controller = $this->getController();
     $request = $controller->getRequest();
 
     $search = id(new PhabricatorApplicationSearchController())
       ->setQueryKey($request->getURIData('queryKey'))
       ->setSearchEngine($this);
 
     return $controller->delegateToController($search);
   }
 
   public function newResultObject() {
     // We may be able to get this automatically if newQuery() is implemented.
     $query = $this->newQuery();
     if ($query) {
       $object = $query->newResultObject();
       if ($object) {
         return $object;
       }
     }
 
     return null;
   }
 
   public function newQuery() {
     return null;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   protected function requireViewer() {
     if (!$this->viewer) {
       throw new PhutilInvalidStateException('setViewer');
     }
     return $this->viewer;
   }
 
   public function setContext($context) {
     $this->context = $context;
     return $this;
   }
 
   public function isPanelContext() {
     return ($this->context == self::CONTEXT_PANEL);
   }
 
   public function setNavigationItems(array $navigation_items) {
     assert_instances_of($navigation_items, 'PHUIListItemView');
     $this->navigationItems = $navigation_items;
     return $this;
   }
 
   public function getNavigationItems() {
     return $this->navigationItems;
   }
 
   public function canUseInPanelContext() {
     return true;
   }
 
   public function saveQuery(PhabricatorSavedQuery $query) {
     if ($query->getID()) {
       throw new Exception(
         pht(
           'Query (with ID "%s") has already been saved. Queries are '.
           'immutable once saved.',
           $query->getID()));
     }
 
     $query->setEngineClassName(get_class($this));
 
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
     try {
       $query->save();
     } catch (AphrontDuplicateKeyQueryException $ex) {
       // Ignore, this is just a repeated search.
     }
     unset($unguarded);
   }
 
   /**
    * Create a saved query object from the request.
    *
-   * @param AphrontRequest The search request.
+   * @param AphrontRequest $request The search request.
    * @return PhabricatorSavedQuery
    */
   public function buildSavedQueryFromRequest(AphrontRequest $request) {
     $fields = $this->buildSearchFields();
     $viewer = $this->requireViewer();
 
     $saved = new PhabricatorSavedQuery();
     foreach ($fields as $field) {
       $field->setViewer($viewer);
 
       $value = $field->readValueFromRequest($request);
       $saved->setParameter($field->getKey(), $value);
     }
 
     return $saved;
   }
 
   /**
    * Executes the saved query.
    *
-   * @param PhabricatorSavedQuery The saved query to operate on.
+   * @param PhabricatorSavedQuery $original The saved query to operate on.
    * @return PhabricatorQuery The result of the query.
    */
   public function buildQueryFromSavedQuery(PhabricatorSavedQuery $original) {
     $saved = clone $original;
     $this->willUseSavedQuery($saved);
 
     $fields = $this->buildSearchFields();
     $viewer = $this->requireViewer();
 
     $map = array();
     foreach ($fields as $field) {
       $field->setViewer($viewer);
       $field->readValueFromSavedQuery($saved);
       $value = $field->getValueForQuery($field->getValue());
       $map[$field->getKey()] = $value;
     }
 
     $original->attachParameterMap($map);
     $query = $this->buildQueryFromParameters($map);
 
     $object = $this->newResultObject();
     if (!$object) {
       return $query;
     }
 
     $extensions = $this->getEngineExtensions();
     foreach ($extensions as $extension) {
       $extension->applyConstraintsToQuery($object, $query, $saved, $map);
     }
 
     $order = $saved->getParameter('order');
     $builtin = $query->getBuiltinOrderAliasMap();
     if (phutil_nonempty_string($order) && isset($builtin[$order])) {
       $query->setOrder($order);
     } else {
       // If the order is invalid or not available, we choose the first
       // builtin order. This isn't always the default order for the query,
       // but is the first value in the "Order" dropdown, and makes the query
       // behavior more consistent with the UI. In queries where the two
       // orders differ, this order is the preferred order for humans.
       $query->setOrder(head_key($builtin));
     }
 
     return $query;
   }
 
   /**
    * Hook for subclasses to adjust saved queries prior to use.
    *
    * If an application changes how queries are saved, it can implement this
    * hook to keep old queries working the way users expect, by reading,
    * adjusting, and overwriting parameters.
    *
-   * @param PhabricatorSavedQuery Saved query which will be executed.
+   * @param PhabricatorSavedQuery $saved Saved query which will be executed.
    * @return void
    */
   protected function willUseSavedQuery(PhabricatorSavedQuery $saved) {
     return;
   }
 
   protected function buildQueryFromParameters(array $parameters) {
     throw new PhutilMethodNotImplementedException();
   }
 
   /**
    * Builds the search form using the request.
    *
-   * @param AphrontFormView       Form to populate.
-   * @param PhabricatorSavedQuery The query from which to build the form.
+   * @param AphrontFormView       $form  Form to populate.
+   * @param PhabricatorSavedQuery $saved Query from which to build the form.
    * @return void
    */
   public function buildSearchForm(
     AphrontFormView $form,
     PhabricatorSavedQuery $saved) {
 
     $saved = clone $saved;
     $this->willUseSavedQuery($saved);
 
     $fields = $this->buildSearchFields();
     $fields = $this->adjustFieldsForDisplay($fields);
     $viewer = $this->requireViewer();
 
     foreach ($fields as $field) {
       $field->setViewer($viewer);
       $field->readValueFromSavedQuery($saved);
     }
 
     foreach ($fields as $field) {
       foreach ($field->getErrors() as $error) {
         $this->addError(last($error));
       }
     }
 
     foreach ($fields as $field) {
       $field->appendToForm($form);
     }
   }
 
   protected function buildSearchFields() {
     $fields = array();
 
     foreach ($this->buildCustomSearchFields() as $field) {
       $fields[] = $field;
     }
 
     $object = $this->newResultObject();
     if ($object) {
       $extensions = $this->getEngineExtensions();
       foreach ($extensions as $extension) {
         $extension_fields = $extension->getSearchFields($object);
         foreach ($extension_fields as $extension_field) {
           $fields[] = $extension_field;
         }
       }
     }
 
     $query = $this->newQuery();
     if ($query && $this->shouldShowOrderField()) {
       $orders = $query->getBuiltinOrders();
       $orders = ipull($orders, 'name');
 
       $fields[] = id(new PhabricatorSearchOrderField())
         ->setLabel(pht('Order By'))
         ->setKey('order')
         ->setOrderAliases($query->getBuiltinOrderAliasMap())
         ->setOptions($orders);
     }
 
     if (id(new PhabricatorAuditApplication())->isInstalled()) {
       $buckets = $this->newResultBuckets();
       if ($query && $buckets) {
         $bucket_options = array(
           self::BUCKET_NONE => pht('No Bucketing'),
         ) + mpull($buckets, 'getResultBucketName');
 
         $fields[] = id(new PhabricatorSearchSelectField())
           ->setLabel(pht('Bucket'))
           ->setKey('bucket')
           ->setOptions($bucket_options);
       }
     }
 
     $field_map = array();
     foreach ($fields as $field) {
       $key = $field->getKey();
       if (isset($field_map[$key])) {
         throw new Exception(
           pht(
             'Two fields in this SearchEngine use the same key ("%s"), but '.
             'each field must use a unique key.',
             $key));
       }
       $field_map[$key] = $field;
     }
 
     return $field_map;
   }
 
   protected function shouldShowOrderField() {
     return true;
   }
 
   private function adjustFieldsForDisplay(array $field_map) {
     $order = $this->getDefaultFieldOrder();
 
     $head_keys = array();
     $tail_keys = array();
     $seen_tail = false;
     foreach ($order as $order_key) {
       if ($order_key === '...') {
         $seen_tail = true;
         continue;
       }
 
       if (!$seen_tail) {
         $head_keys[] = $order_key;
       } else {
         $tail_keys[] = $order_key;
       }
     }
 
     $head = array_select_keys($field_map, $head_keys);
     $body = array_diff_key($field_map, array_fuse($tail_keys));
     $tail = array_select_keys($field_map, $tail_keys);
 
     $result = $head + $body + $tail;
 
     // Force the fulltext "query" field to the top unconditionally.
     $result = array_select_keys($result, array('query')) + $result;
 
     foreach ($this->getHiddenFields() as $hidden_key) {
       unset($result[$hidden_key]);
     }
 
     return $result;
   }
 
   protected function buildCustomSearchFields() {
     throw new PhutilMethodNotImplementedException();
   }
 
 
   /**
    * Define the default display order for fields by returning a list of
    * field keys.
    *
    * You can use the special key `...` to mean "all unspecified fields go
    * here". This lets you easily put important fields at the top of the form,
    * standard fields in the middle of the form, and less important fields at
    * the bottom.
    *
    * For example, you might return a list like this:
    *
    *   return array(
    *     'authorPHIDs',
    *     'reviewerPHIDs',
    *     '...',
    *     'createdAfter',
    *     'createdBefore',
    *   );
    *
    * Any unspecified fields (including custom fields and fields added
    * automatically by infrastructure) will be put in the middle.
    *
    * @return list<string> Default ordering for field keys.
    */
   protected function getDefaultFieldOrder() {
     return array();
   }
 
   /**
    * Return a list of field keys which should be hidden from the viewer.
    *
     * @return list<string> Fields to hide.
    */
   protected function getHiddenFields() {
     return array();
   }
 
   public function getErrors() {
     return $this->errors;
   }
 
   public function addError($error) {
     $this->errors[] = $error;
     return $this;
   }
 
   /**
    * Return an application URI corresponding to the results page of a query.
    * Normally, this is something like `/application/query/QUERYKEY/`.
    *
-   * @param   string  The query key to build a URI for.
+   * @param   string  $query_key The query key to build a URI for.
    * @return  string  URI where the query can be executed.
    * @task uri
    */
   public function getQueryResultsPageURI($query_key) {
     return $this->getURI('query/'.$query_key.'/');
   }
 
 
   /**
    * Return an application URI for query management. This is used when, e.g.,
    * a query deletion operation is cancelled.
    *
    * @return  string  URI where queries can be managed.
    * @task uri
    */
   public function getQueryManagementURI() {
     return $this->getURI('query/edit/');
   }
 
   public function getQueryBaseURI() {
     return $this->getURI('');
   }
 
   public function getExportURI($query_key) {
     return $this->getURI('query/'.$query_key.'/export/');
   }
 
   public function getCustomizeURI($query_key, $object_phid, $context_phid) {
     $params = array(
       'search.objectPHID' => $object_phid,
       'search.contextPHID' => $context_phid,
     );
 
     $uri = $this->getURI('query/'.$query_key.'/customize/');
     $uri = new PhutilURI($uri, $params);
 
     return phutil_string_cast($uri);
   }
 
 
 
   /**
    * Return the URI to a path within the application. Used to construct default
    * URIs for management and results.
    *
    * @return string URI to path.
    * @task uri
    */
   abstract protected function getURI($path);
 
 
   /**
    * Return a human readable description of the type of objects this query
    * searches for.
    *
    * For example, "Tasks" or "Commits".
    *
    * @return string Human-readable description of what this engine is used to
    *   find.
    */
   abstract public function getResultTypeDescription();
 
 
   public function newSavedQuery() {
     return id(new PhabricatorSavedQuery())
       ->setEngineClassName(get_class($this));
   }
 
   public function addNavigationItems(PHUIListView $menu) {
     $viewer = $this->requireViewer();
 
     $menu->newLabel(pht('Queries'));
 
     $named_queries = $this->loadEnabledNamedQueries();
 
     foreach ($named_queries as $query) {
       $key = $query->getQueryKey();
       $uri = $this->getQueryResultsPageURI($key);
       $menu->newLink($query->getQueryName(), $uri, 'query/'.$key);
     }
 
     if ($viewer->isLoggedIn()) {
       $manage_uri = $this->getQueryManagementURI();
       $menu->newLink(pht('Edit Queries...'), $manage_uri, 'query/edit');
     }
 
     $menu->newLabel(pht('Search'));
     $advanced_uri = $this->getQueryResultsPageURI('advanced');
     $menu->newLink(pht('Advanced Search'), $advanced_uri, 'query/advanced');
 
     foreach ($this->navigationItems as $extra_item) {
       $menu->addMenuItem($extra_item);
     }
 
     return $this;
   }
 
   public function loadAllNamedQueries() {
     $viewer = $this->requireViewer();
     $builtin = $this->getBuiltinQueries();
 
     if ($this->namedQueries === null) {
       $named_queries = id(new PhabricatorNamedQueryQuery())
         ->setViewer($viewer)
         ->withEngineClassNames(array(get_class($this)))
         ->withUserPHIDs(
           array(
             $viewer->getPHID(),
             PhabricatorNamedQuery::SCOPE_GLOBAL,
           ))
         ->execute();
       $named_queries = mpull($named_queries, null, 'getQueryKey');
 
       $builtin = mpull($builtin, null, 'getQueryKey');
 
       foreach ($named_queries as $key => $named_query) {
         if ($named_query->getIsBuiltin()) {
           if (isset($builtin[$key])) {
             $named_queries[$key]->setQueryName($builtin[$key]->getQueryName());
             unset($builtin[$key]);
           } else {
             unset($named_queries[$key]);
           }
         }
 
         unset($builtin[$key]);
       }
 
       $named_queries = msortv($named_queries, 'getNamedQuerySortVector');
       $this->namedQueries = $named_queries;
     }
 
     return $this->namedQueries + $builtin;
   }
 
   public function loadEnabledNamedQueries() {
     $named_queries = $this->loadAllNamedQueries();
     foreach ($named_queries as $key => $named_query) {
       if ($named_query->getIsBuiltin() && $named_query->getIsDisabled()) {
         unset($named_queries[$key]);
       }
     }
     return $named_queries;
   }
 
   public function getDefaultQueryKey() {
     $viewer = $this->requireViewer();
 
     $configs = id(new PhabricatorNamedQueryConfigQuery())
       ->setViewer($viewer)
       ->withEngineClassNames(array(get_class($this)))
       ->withScopePHIDs(
         array(
           $viewer->getPHID(),
           PhabricatorNamedQueryConfig::SCOPE_GLOBAL,
         ))
       ->execute();
     $configs = msortv($configs, 'getStrengthSortVector');
 
     $key_pinned = PhabricatorNamedQueryConfig::PROPERTY_PINNED;
     $map = $this->loadEnabledNamedQueries();
     foreach ($configs as $config) {
       $pinned = $config->getConfigProperty($key_pinned);
       if (!isset($map[$pinned])) {
         continue;
       }
 
       return $pinned;
     }
 
     return head_key($map);
   }
 
   protected function setQueryProjects(
     PhabricatorCursorPagedPolicyAwareQuery $query,
     PhabricatorSavedQuery $saved) {
 
     $datasource = id(new PhabricatorProjectLogicalDatasource())
       ->setViewer($this->requireViewer());
 
     $projects = $saved->getParameter('projects', array());
     $constraints = $datasource->evaluateTokens($projects);
 
     if ($constraints) {
       $query->withEdgeLogicConstraints(
         PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
         $constraints);
     }
 
     return $this;
   }
 
 
 /* -(  Applications  )------------------------------------------------------- */
 
 
   protected function getApplicationURI($path = '') {
     return $this->getApplication()->getApplicationURI($path);
   }
 
   protected function getApplication() {
     if (!$this->application) {
       $class = $this->getApplicationClassName();
 
       $this->application = id(new PhabricatorApplicationQuery())
         ->setViewer($this->requireViewer())
         ->withClasses(array($class))
         ->withInstalled(true)
         ->executeOne();
 
       if (!$this->application) {
         throw new Exception(
           pht(
             'Application "%s" is not installed!',
             $class));
       }
     }
 
     return $this->application;
   }
 
   abstract public function getApplicationClassName();
 
 
 /* -(  Constructing Engines  )----------------------------------------------- */
 
 
   /**
    * Load all available application search engines.
    *
    * @return list<PhabricatorApplicationSearchEngine> All available engines.
    * @task construct
    */
   public static function getAllEngines() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->execute();
   }
 
 
   /**
    * Get an engine by class name, if it exists.
    *
    * @return PhabricatorApplicationSearchEngine|null Engine, or null if it does
    *   not exist.
    * @task construct
    */
   public static function getEngineByClassName($class_name) {
     return idx(self::getAllEngines(), $class_name);
   }
 
 
 /* -(  Builtin Queries  )---------------------------------------------------- */
 
 
   /**
    * @task builtin
    */
   public function getBuiltinQueries() {
     $names = $this->getBuiltinQueryNames();
 
     $queries = array();
     $sequence = 0;
     foreach ($names as $key => $name) {
       $queries[$key] = id(new PhabricatorNamedQuery())
         ->setUserPHID(PhabricatorNamedQuery::SCOPE_GLOBAL)
         ->setEngineClassName(get_class($this))
         ->setQueryName($name)
         ->setQueryKey($key)
         ->setSequence((1 << 24) + $sequence++)
         ->setIsBuiltin(true);
     }
 
     return $queries;
   }
 
 
   /**
    * @task builtin
    */
   public function getBuiltinQuery($query_key) {
     if (!$this->isBuiltinQuery($query_key)) {
       throw new Exception(pht("'%s' is not a builtin!", $query_key));
     }
     return idx($this->getBuiltinQueries(), $query_key);
   }
 
 
   /**
    * @task builtin
    */
   protected function getBuiltinQueryNames() {
     return array();
   }
 
 
   /**
    * @task builtin
    */
   public function isBuiltinQuery($query_key) {
     $builtins = $this->getBuiltinQueries();
     return isset($builtins[$query_key]);
   }
 
 
   /**
    * @task builtin
    */
   public function buildSavedQueryFromBuiltin($query_key) {
     throw new Exception(pht("Builtin '%s' is not supported!", $query_key));
   }
 
 
 /* -(  Reading Utilities )--------------------------------------------------- */
 
 
   /**
    * Read a list of user PHIDs from a request in a flexible way. This method
    * supports either of these forms:
    *
    *   users[]=alincoln&users[]=htaft
    *   users=alincoln,htaft
    *
    * Additionally, users can be specified either by PHID or by name.
    *
    * The main goal of this flexibility is to allow external programs to generate
    * links to pages (like "alincoln's open revisions") without needing to make
    * API calls.
    *
-   * @param AphrontRequest  Request to read user PHIDs from.
-   * @param string          Key to read in the request.
-   * @param list<const>     Other permitted PHID types.
+   * @param AphrontRequest  $request Request to read user PHIDs from.
+   * @param string          $key Key to read in the request.
+   * @param list<const>?    $allow_types Other permitted PHID types.
    * @return list<phid>     List of user PHIDs and selector functions.
    * @task read
    */
   protected function readUsersFromRequest(
     AphrontRequest $request,
     $key,
     array $allow_types = array()) {
 
     $list = $this->readListFromRequest($request, $key);
 
     $phids = array();
     $names = array();
     $allow_types = array_fuse($allow_types);
     $user_type = PhabricatorPeopleUserPHIDType::TYPECONST;
     foreach ($list as $item) {
       $type = phid_get_type($item);
       if ($type == $user_type) {
         $phids[] = $item;
       } else if (isset($allow_types[$type])) {
         $phids[] = $item;
       } else {
         if (PhabricatorTypeaheadDatasource::isFunctionToken($item)) {
           // If this is a function, pass it through unchanged; we'll evaluate
           // it later.
           $phids[] = $item;
         } else {
           $names[] = $item;
         }
       }
     }
 
     if ($names) {
       $users = id(new PhabricatorPeopleQuery())
         ->setViewer($this->requireViewer())
         ->withUsernames($names)
         ->execute();
       foreach ($users as $user) {
         $phids[] = $user->getPHID();
       }
       $phids = array_unique($phids);
     }
 
     return $phids;
   }
 
 
   /**
    * Read a list of subscribers from a request in a flexible way.
    *
-   * @param AphrontRequest  Request to read PHIDs from.
-   * @param string          Key to read in the request.
+   * @param AphrontRequest  $request Request to read PHIDs from.
+   * @param string          $key Key to read in the request.
    * @return list<phid>     List of object PHIDs.
    * @task read
    */
   protected function readSubscribersFromRequest(
     AphrontRequest $request,
     $key) {
     return $this->readUsersFromRequest(
       $request,
       $key,
       array(
         PhabricatorProjectProjectPHIDType::TYPECONST,
       ));
   }
 
 
   /**
    * Read a list of generic PHIDs from a request in a flexible way. Like
    * @{method:readUsersFromRequest}, this method supports either array or
    * comma-delimited forms. Objects can be specified either by PHID or by
    * object name.
    *
-   * @param AphrontRequest  Request to read PHIDs from.
-   * @param string          Key to read in the request.
-   * @param list<const>     Optional, list of permitted PHID types.
+   * @param AphrontRequest  $request Request to read PHIDs from.
+   * @param string          $key Key to read in the request.
+   * @param list<const>?    $allow_types Optional, list of permitted PHID
+   *                        types.
    * @return list<phid>     List of object PHIDs.
    *
    * @task read
    */
   protected function readPHIDsFromRequest(
     AphrontRequest $request,
     $key,
     array $allow_types = array()) {
 
     $list = $this->readListFromRequest($request, $key);
 
     $objects = id(new PhabricatorObjectQuery())
       ->setViewer($this->requireViewer())
       ->withNames($list)
       ->execute();
     $list = mpull($objects, 'getPHID');
 
     if (!$list) {
       return array();
     }
 
     // If only certain PHID types are allowed, filter out all the others.
     if ($allow_types) {
       $allow_types = array_fuse($allow_types);
       foreach ($list as $key => $phid) {
         if (empty($allow_types[phid_get_type($phid)])) {
           unset($list[$key]);
         }
       }
     }
 
     return $list;
   }
 
 
   /**
    * Read a list of items from the request, in either array format or string
    * format:
    *
    *   list[]=item1&list[]=item2
    *   list=item1,item2
    *
    * This provides flexibility when constructing URIs, especially from external
    * sources.
    *
-   * @param AphrontRequest  Request to read strings from.
-   * @param string          Key to read in the request.
+   * @param AphrontRequest  $request Request to read strings from.
+   * @param string          $key Key to read in the request.
    * @return list<string>   List of values.
    */
   protected function readListFromRequest(
     AphrontRequest $request,
     $key) {
     $list = $request->getArr($key, null);
     if ($list === null) {
       $list = $request->getStrList($key);
     }
 
     if (!$list) {
       return array();
     }
 
     return $list;
   }
 
   protected function readBoolFromRequest(
     AphrontRequest $request,
     $key) {
     if (!phutil_nonempty_string($request->getStr($key))) {
       return null;
     }
     return $request->getBool($key);
   }
 
 
   protected function getBoolFromQuery(PhabricatorSavedQuery $query, $key) {
     $value = $query->getParameter($key);
     if ($value === null) {
       return $value;
     }
     return $value ? 'true' : 'false';
   }
 
 
 /* -(  Dates  )-------------------------------------------------------------- */
 
 
   /**
    * @task dates
    */
   protected function parseDateTime($date_time) {
     if (!strlen($date_time)) {
       return null;
     }
 
     return PhabricatorTime::parseLocalTime($date_time, $this->requireViewer());
   }
 
 
   /**
    * @task dates
    */
   protected function buildDateRange(
     AphrontFormView $form,
     PhabricatorSavedQuery $saved_query,
     $start_key,
     $start_name,
     $end_key,
     $end_name) {
 
     $start_str = $saved_query->getParameter($start_key);
     $start = null;
     if (strlen($start_str)) {
       $start = $this->parseDateTime($start_str);
       if (!$start) {
         $this->addError(
           pht(
             '"%s" date can not be parsed.',
             $start_name));
       }
     }
 
 
     $end_str = $saved_query->getParameter($end_key);
     $end = null;
     if (strlen($end_str)) {
       $end = $this->parseDateTime($end_str);
       if (!$end) {
         $this->addError(
           pht(
             '"%s" date can not be parsed.',
             $end_name));
       }
     }
 
     if ($start && $end && ($start >= $end)) {
       $this->addError(
         pht(
           '"%s" must be a date before "%s".',
           $start_name,
           $end_name));
     }
 
     $form
       ->appendChild(
         id(new PHUIFormFreeformDateControl())
           ->setName($start_key)
           ->setLabel($start_name)
           ->setValue($start_str))
       ->appendChild(
         id(new AphrontFormTextControl())
           ->setName($end_key)
           ->setLabel($end_name)
           ->setValue($end_str));
   }
 
 
 /* -(  Paging and Executing Queries  )--------------------------------------- */
 
 
   protected function newResultBuckets() {
     return array();
   }
 
   public function getResultBucket(PhabricatorSavedQuery $saved) {
     $key = $saved->getParameter('bucket');
     if ($key == self::BUCKET_NONE) {
       return null;
     }
 
     $buckets = $this->newResultBuckets();
     return idx($buckets, $key);
   }
 
 
   public function getPageSize(PhabricatorSavedQuery $saved) {
     $bucket = $this->getResultBucket($saved);
 
     $limit = (int)$saved->getParameter('limit');
 
     if ($limit > 0) {
       if ($bucket) {
         $bucket->setPageSize($limit);
       }
       return $limit;
     }
 
     if ($bucket) {
       return $bucket->getPageSize();
     }
 
     return 100;
   }
 
 
   public function shouldUseOffsetPaging() {
     return false;
   }
 
 
   public function newPagerForSavedQuery(PhabricatorSavedQuery $saved) {
     if ($this->shouldUseOffsetPaging()) {
       $pager = new PHUIPagerView();
     } else {
       $pager = new AphrontCursorPagerView();
     }
 
     $page_size = $this->getPageSize($saved);
     if (is_finite($page_size)) {
       $pager->setPageSize($page_size);
     } else {
       // Consider an INF pagesize to mean a large finite pagesize.
 
       // TODO: It would be nice to handle this more gracefully, but math
       // with INF seems to vary across PHP versions, systems, and runtimes.
       $pager->setPageSize(0xFFFF);
     }
 
     return $pager;
   }
 
 
   public function executeQuery(
     PhabricatorPolicyAwareQuery $query,
     AphrontView $pager) {
 
     $query->setViewer($this->requireViewer());
 
     if ($this->shouldUseOffsetPaging()) {
       $objects = $query->executeWithOffsetPager($pager);
     } else {
       $objects = $query->executeWithCursorPager($pager);
     }
 
     $this->didExecuteQuery($query);
 
     return $objects;
   }
 
   protected function didExecuteQuery(PhabricatorPolicyAwareQuery $query) {
     return;
   }
 
 
 /* -(  Rendering  )---------------------------------------------------------- */
 
 
   public function setRequest(AphrontRequest $request) {
     $this->request = $request;
     return $this;
   }
 
   public function getRequest() {
     return $this->request;
   }
 
   public function renderResults(
     array $objects,
     PhabricatorSavedQuery $query) {
 
     $phids = $this->getRequiredHandlePHIDsForResultList($objects, $query);
 
     if ($phids) {
       $handles = id(new PhabricatorHandleQuery())
         ->setViewer($this->requireViewer())
         ->withPHIDs($phids)
         ->execute();
     } else {
       $handles = array();
     }
 
     return $this->renderResultList($objects, $query, $handles);
   }
 
   protected function getRequiredHandlePHIDsForResultList(
     array $objects,
     PhabricatorSavedQuery $query) {
     return array();
   }
 
   abstract protected function renderResultList(
     array $objects,
     PhabricatorSavedQuery $query,
     array $handles);
 
 
 /* -(  Application Search  )------------------------------------------------- */
 
 
   public function getSearchFieldsForConduit() {
     $standard_fields = $this->buildSearchFields();
 
     $fields = array();
     foreach ($standard_fields as $field_key => $field) {
       $conduit_key = $field->getConduitKey();
 
       if (isset($fields[$conduit_key])) {
         $other = $fields[$conduit_key];
         $other_key = $other->getKey();
 
         throw new Exception(
           pht(
             'SearchFields "%s" (of class "%s") and "%s" (of class "%s") both '.
             'define the same Conduit key ("%s"). Keys must be unique.',
             $field_key,
             get_class($field),
             $other_key,
             get_class($other),
             $conduit_key));
       }
 
       $fields[$conduit_key] = $field;
     }
 
     // These are handled separately for Conduit, so don't show them as
     // supported.
     unset($fields['order']);
     unset($fields['limit']);
 
     $viewer = $this->requireViewer();
     foreach ($fields as $key => $field) {
       $field->setViewer($viewer);
     }
 
     return $fields;
   }
 
   public function buildConduitResponse(
     ConduitAPIRequest $request,
     ConduitAPIMethod $method) {
     $viewer = $this->requireViewer();
 
     $query_key = $request->getValue('queryKey');
     $is_empty_query_key = phutil_string_cast($query_key) === '';
     if ($is_empty_query_key) {
       $saved_query = new PhabricatorSavedQuery();
     } else if ($this->isBuiltinQuery($query_key)) {
       $saved_query = $this->buildSavedQueryFromBuiltin($query_key);
     } else {
       $saved_query = id(new PhabricatorSavedQueryQuery())
         ->setViewer($viewer)
         ->withQueryKeys(array($query_key))
         ->executeOne();
       if (!$saved_query) {
         throw new Exception(
           pht(
             'Query key "%s" does not correspond to a valid query.',
             $query_key));
       }
     }
 
     $constraints = $request->getValue('constraints', array());
     if (!is_array($constraints)) {
       throw new Exception(
         pht(
           'Parameter "constraints" must be a map of constraints, got "%s".',
           phutil_describe_type($constraints)));
     }
 
     $fields = $this->getSearchFieldsForConduit();
 
     foreach ($fields as $key => $field) {
       if (!$field->getConduitParameterType()) {
         unset($fields[$key]);
       }
     }
 
     $valid_constraints = array();
     foreach ($fields as $field) {
       foreach ($field->getValidConstraintKeys() as $key) {
         $valid_constraints[$key] = true;
       }
     }
 
     foreach ($constraints as $key => $constraint) {
       if (empty($valid_constraints[$key])) {
         throw new Exception(
           pht(
             'Constraint "%s" is not a valid constraint for this query.',
             $key));
       }
     }
 
     foreach ($fields as $field) {
       if (!$field->getValueExistsInConduitRequest($constraints)) {
         continue;
       }
 
       $value = $field->readValueFromConduitRequest(
         $constraints,
         $request->getIsStrictlyTyped());
       $saved_query->setParameter($field->getKey(), $value);
     }
 
     // NOTE: Currently, when running an ad-hoc query we never persist it into
     // a saved query. We might want to add an option to do this in the future
     // (for example, to enable a CLI-to-Web workflow where user can view more
     // details about results by following a link), but have no use cases for
     // it today. If we do identify a use case, we could save the query here.
 
     $query = $this->buildQueryFromSavedQuery($saved_query);
     $pager = $this->newPagerForSavedQuery($saved_query);
 
     $attachments = $this->getConduitSearchAttachments();
 
     // TODO: Validate this better.
     $attachment_specs = $request->getValue('attachments', array());
     $attachments = array_select_keys(
       $attachments,
       array_keys($attachment_specs));
 
     foreach ($attachments as $key => $attachment) {
       $attachment->setViewer($viewer);
     }
 
     foreach ($attachments as $key => $attachment) {
       $attachment->willLoadAttachmentData($query, $attachment_specs[$key]);
     }
 
     $this->setQueryOrderForConduit($query, $request);
     $this->setPagerLimitForConduit($pager, $request);
     $this->setPagerOffsetsForConduit($pager, $request);
 
     $objects = $this->executeQuery($query, $pager);
 
     $data = array();
     if ($objects) {
       $field_extensions = $this->getConduitFieldExtensions();
 
       $extension_data = array();
       foreach ($field_extensions as $key => $extension) {
         $extension_data[$key] = $extension->loadExtensionConduitData($objects);
       }
 
       $attachment_data = array();
       foreach ($attachments as $key => $attachment) {
         $attachment_data[$key] = $attachment->loadAttachmentData(
           $objects,
           $attachment_specs[$key]);
       }
 
       foreach ($objects as $object) {
         $field_map = $this->getObjectWireFieldsForConduit(
           $object,
           $field_extensions,
           $extension_data);
 
         $attachment_map = array();
         foreach ($attachments as $key => $attachment) {
           $attachment_map[$key] = $attachment->getAttachmentForObject(
             $object,
             $attachment_data[$key],
             $attachment_specs[$key]);
         }
 
         // If this is empty, we still want to emit a JSON object, not a
         // JSON list.
         if (!$attachment_map) {
           $attachment_map = (object)$attachment_map;
         }
 
         $id = (int)$object->getID();
         $phid = $object->getPHID();
 
         $data[] = array(
           'id' => $id,
           'type' => phid_get_type($phid),
           'phid' => $phid,
           'fields' => $field_map,
           'attachments' => $attachment_map,
         );
       }
     }
 
     return array(
       'data' => $data,
       'maps' => $method->getQueryMaps($query),
       'query' => array(
         // This may be `null` if we have not saved the query.
         'queryKey' => $saved_query->getQueryKey(),
       ),
       'cursor' => array(
         'limit' => $pager->getPageSize(),
         'after' => $pager->getNextPageID(),
         'before' => $pager->getPrevPageID(),
         'order' => $request->getValue('order'),
       ),
     );
   }
 
   public function getAllConduitFieldSpecifications() {
     $extensions = $this->getConduitFieldExtensions();
     $object = $this->newQuery()->newResultObject();
 
     $map = array();
     foreach ($extensions as $extension) {
       $specifications = $extension->getFieldSpecificationsForConduit($object);
       foreach ($specifications as $specification) {
         $key = $specification->getKey();
         if (isset($map[$key])) {
           throw new Exception(
             pht(
               'Two field specifications share the same key ("%s"). Each '.
               'specification must have a unique key.',
               $key));
         }
         $map[$key] = $specification;
       }
     }
 
     return $map;
   }
 
   private function getEngineExtensions() {
     $extensions = PhabricatorSearchEngineExtension::getAllEnabledExtensions();
 
     foreach ($extensions as $key => $extension) {
       $extension
         ->setViewer($this->requireViewer())
         ->setSearchEngine($this);
     }
 
     $object = $this->newResultObject();
     foreach ($extensions as $key => $extension) {
       if (!$extension->supportsObject($object)) {
         unset($extensions[$key]);
       }
     }
 
     return $extensions;
   }
 
 
   private function getConduitFieldExtensions() {
     $extensions = $this->getEngineExtensions();
     $object = $this->newResultObject();
 
     foreach ($extensions as $key => $extension) {
       if (!$extension->getFieldSpecificationsForConduit($object)) {
         unset($extensions[$key]);
       }
     }
 
     return $extensions;
   }
 
   private function setQueryOrderForConduit($query, ConduitAPIRequest $request) {
     $order = $request->getValue('order');
     if ($order === null) {
       return;
     }
 
     if (is_scalar($order)) {
       $query->setOrder($order);
     } else {
       $query->setOrderVector($order);
     }
   }
 
   private function setPagerLimitForConduit($pager, ConduitAPIRequest $request) {
     $limit = $request->getValue('limit');
 
     // If there's no limit specified and the query uses a weird huge page
     // size, just leave it at the default gigantic page size. Otherwise,
     // make sure it's between 1 and 100, inclusive.
 
     if ($limit === null) {
       if ($pager->getPageSize() >= 0xFFFF) {
         return;
       } else {
         $limit = 100;
       }
     }
 
     if ($limit > 100) {
       throw new Exception(
         pht(
           'Maximum page size for Conduit API method calls is 100, but '.
           'this call specified %s.',
           $limit));
     }
 
     if ($limit < 1) {
       throw new Exception(
         pht(
           'Minimum page size for API searches is 1, but this call '.
           'specified %s.',
           $limit));
     }
 
     $pager->setPageSize($limit);
   }
 
   private function setPagerOffsetsForConduit(
     $pager,
     ConduitAPIRequest $request) {
     $before_id = $request->getValue('before');
     if ($before_id !== null) {
       $pager->setBeforeID($before_id);
     }
 
     $after_id = $request->getValue('after');
     if ($after_id !== null) {
       $pager->setAfterID($after_id);
     }
   }
 
   protected function getObjectWireFieldsForConduit(
     $object,
     array $field_extensions,
     array $extension_data) {
 
     $fields = array();
     foreach ($field_extensions as $key => $extension) {
       $data = idx($extension_data, $key, array());
       $fields += $extension->getFieldValuesForConduit($object, $data);
     }
 
     return $fields;
   }
 
   public function getConduitSearchAttachments() {
     $extensions = $this->getEngineExtensions();
     $object = $this->newResultObject();
 
     $attachments = array();
     foreach ($extensions as $extension) {
       $extension_attachments = $extension->getSearchAttachments($object);
       foreach ($extension_attachments as $attachment) {
         $attachment_key = $attachment->getAttachmentKey();
         if (isset($attachments[$attachment_key])) {
           $other = $attachments[$attachment_key];
           throw new Exception(
             pht(
               'Two search engine attachments (of classes "%s" and "%s") '.
               'specify the same attachment key ("%s"); keys must be unique.',
               get_class($attachment),
               get_class($other),
               $attachment_key));
         }
         $attachments[$attachment_key] = $attachment;
       }
     }
 
     return $attachments;
   }
 
   /**
    * Render a content body (if available) to onboard new users.
    * This body is usually visible when you have no elements in a list,
    * or when you force the rendering on a list with the `?nux=1` URL.
    * @return wild|PhutilSafeHTML|null
    */
   final public function renderNewUserView() {
     $body = $this->getNewUserBody();
 
     if (!$body) {
       return null;
     }
 
     return $body;
   }
 
   /**
    * Get a content body to onboard new users.
    * Traditionally this content is shown from an empty list, to explain
    * what a certain entity does, and how to create a new one.
    * @return wild|PhutilSafeHTML|null
    */
   protected function getNewUserHeader() {
     return null;
   }
 
   protected function getNewUserBody() {
     return null;
   }
 
   public function newUseResultsActions(PhabricatorSavedQuery $saved) {
     return array();
   }
 
 
 /* -(  Export  )------------------------------------------------------------- */
 
 
   public function canExport() {
     $fields = $this->newExportFields();
     return (bool)$fields;
   }
 
   final public function newExportFieldList() {
     $object = $this->newResultObject();
 
     $builtin_fields = array(
       id(new PhabricatorIDExportField())
         ->setKey('id')
         ->setLabel(pht('ID')),
     );
 
     if ($object->getConfigOption(LiskDAO::CONFIG_AUX_PHID)) {
       $builtin_fields[] = id(new PhabricatorPHIDExportField())
         ->setKey('phid')
         ->setLabel(pht('PHID'));
     }
 
     $fields = mpull($builtin_fields, null, 'getKey');
 
     $export_fields = $this->newExportFields();
     foreach ($export_fields as $export_field) {
       $key = $export_field->getKey();
 
       if (isset($fields[$key])) {
         throw new Exception(
           pht(
             'Search engine ("%s") defines an export field with a key ("%s") '.
             'that collides with another field. Each field must have a '.
             'unique key.',
             get_class($this),
             $key));
       }
 
       $fields[$key] = $export_field;
     }
 
     $extensions = $this->newExportExtensions();
     foreach ($extensions as $extension) {
       $extension_fields = $extension->newExportFields();
       foreach ($extension_fields as $extension_field) {
         $key = $extension_field->getKey();
 
         if (isset($fields[$key])) {
           throw new Exception(
             pht(
               'Export engine extension ("%s") defines an export field with '.
               'a key ("%s") that collides with another field. Each field '.
               'must have a unique key.',
               get_class($extension_field),
               $key));
         }
 
         $fields[$key] = $extension_field;
       }
     }
 
     return $fields;
   }
 
   final public function newExport(array $objects) {
     $object = $this->newResultObject();
     $has_phid = $object->getConfigOption(LiskDAO::CONFIG_AUX_PHID);
 
     $objects = array_values($objects);
     $n = count($objects);
 
     $maps = array();
     foreach ($objects as $object) {
       $map = array(
         'id' => $object->getID(),
       );
 
       if ($has_phid) {
         $map['phid'] = $object->getPHID();
       }
 
       $maps[] = $map;
     }
 
     $export_data = $this->newExportData($objects);
     $export_data = array_values($export_data);
     if (count($export_data) !== count($objects)) {
       throw new Exception(
         pht(
           'Search engine ("%s") exported the wrong number of objects, '.
           'expected %s but got %s.',
           get_class($this),
           phutil_count($objects),
           phutil_count($export_data)));
     }
 
     for ($ii = 0; $ii < $n; $ii++) {
       $maps[$ii] += $export_data[$ii];
     }
 
     $extensions = $this->newExportExtensions();
     foreach ($extensions as $extension) {
       $extension_data = $extension->newExportData($objects);
       $extension_data = array_values($extension_data);
       if (count($export_data) !== count($objects)) {
         throw new Exception(
           pht(
             'Export engine extension ("%s") exported the wrong number of '.
             'objects, expected %s but got %s.',
             get_class($extension),
             phutil_count($objects),
             phutil_count($export_data)));
       }
 
       for ($ii = 0; $ii < $n; $ii++) {
         $maps[$ii] += $extension_data[$ii];
       }
     }
 
     return $maps;
   }
 
   protected function newExportFields() {
     return array();
   }
 
   protected function newExportData(array $objects) {
     throw new PhutilMethodNotImplementedException();
   }
 
   private function newExportExtensions() {
     $object = $this->newResultObject();
     $viewer = $this->requireViewer();
 
     $extensions = PhabricatorExportEngineExtension::getAllExtensions();
 
     $supported = array();
     foreach ($extensions as $extension) {
       $extension = clone $extension;
       $extension->setViewer($viewer);
 
       if ($extension->supportsObject($object)) {
         $supported[] = $extension;
       }
     }
 
     return $supported;
   }
 
   /**
    * Load from object and from storage, and updates Custom Fields instances
    * that are attached to each object.
    *
    * @return map<phid->PhabricatorCustomFieldList> of loaded fields.
    * @task custom
    */
   protected function loadCustomFields(array $objects, $role) {
     assert_instances_of($objects, 'PhabricatorCustomFieldInterface');
 
     $query = new PhabricatorCustomFieldStorageQuery();
     $lists = array();
 
     foreach ($objects as $object) {
       $field_list = PhabricatorCustomField::getObjectFields($object, $role);
       $field_list->readFieldsFromObject($object);
       foreach ($field_list->getFields() as $field) {
         // TODO move $viewer into PhabricatorCustomFieldStorageQuery
         $field->setViewer($this->viewer);
       }
       $lists[$object->getPHID()] = $field_list;
       $query->addFields($field_list->getFields());
     }
     // This updates the field_list objects.
     $query->execute();
 
     return $lists;
   }
 
 }
diff --git a/src/applications/search/field/PhabricatorSearchField.php b/src/applications/search/field/PhabricatorSearchField.php
index 36db0523b7..7b420ac8e6 100644
--- a/src/applications/search/field/PhabricatorSearchField.php
+++ b/src/applications/search/field/PhabricatorSearchField.php
@@ -1,426 +1,426 @@
 <?php
 
 /**
  * @task config Configuring Fields
  * @task error Handling Errors
  * @task io Reading and Writing Field Values
  * @task conduit Integration with Conduit
  * @task util Utility Methods
  */
 abstract class PhabricatorSearchField extends Phobject {
 
   private $key;
   private $conduitKey;
   private $viewer;
   private $value;
   private $label;
   private $aliases = array();
   private $errors = array();
   private $description;
   private $isHidden;
 
   private $enableForConduit = true;
 
 
 /* -(  Configuring Fields  )------------------------------------------------- */
 
 
   /**
    * Set the primary key for the field, like `projectPHIDs`.
    *
    * You can set human-readable aliases with @{method:setAliases}.
    *
    * The key should be a short, unique (within a search engine) string which
    * does not contain any special characters.
    *
-   * @param string Unique key which identifies the field.
+   * @param string $key Unique key which identifies the field.
    * @return this
    * @task config
    */
   public function setKey($key) {
     $this->key = $key;
     return $this;
   }
 
 
   /**
    * Get the field's key.
    *
    * @return string Unique key for this field.
    * @task config
    */
   public function getKey() {
     return $this->key;
   }
 
 
   /**
    * Set a human-readable label for the field.
    *
    * This should be a short text string, like "Reviewers" or "Colors".
    *
-   * @param string Short, human-readable field label.
+   * @param string $label Short, human-readable field label.
    * @return this
    * task config
    */
   public function setLabel($label) {
     $this->label = $label;
     return $this;
   }
 
 
   /**
    * Get the field's human-readable label.
    *
    * @return string Short, human-readable field label.
    * @task config
    */
   public function getLabel() {
     return $this->label;
   }
 
 
   /**
    * Set the acting viewer.
    *
    * Engines do not need to do this explicitly; it will be done on their
    * behalf by the caller.
    *
-   * @param PhabricatorUser Viewer.
+   * @param PhabricatorUser $viewer Viewer.
    * @return this
    * @task config
    */
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
 
   /**
    * Get the acting viewer.
    *
    * @return PhabricatorUser Viewer.
    * @task config
    */
   public function getViewer() {
     return $this->viewer;
   }
 
 
   /**
    * Provide alternate field aliases, usually more human-readable versions
    * of the key.
    *
    * These aliases can be used when building GET requests, so you can provide
    * an alias like `authors` to let users write `&authors=alincoln` instead of
    * `&authorPHIDs=alincoln`. This is a little easier to use.
    *
-   * @param list<string> List of aliases for this field.
+   * @param list<string> $aliases List of aliases for this field.
    * @return this
    * @task config
    */
   public function setAliases(array $aliases) {
     $this->aliases = $aliases;
     return $this;
   }
 
 
   /**
    * Get aliases for this field.
    *
    * @return list<string> List of aliases for this field.
    * @task config
    */
   public function getAliases() {
     return $this->aliases;
   }
 
 
   /**
    * Provide an alternate field key for Conduit.
    *
    * This can allow you to choose a more usable key for API endpoints.
    * If no key is provided, the main key is used.
    *
-   * @param string Alternate key for Conduit.
+   * @param string $conduit_key Alternate key for Conduit.
    * @return this
    * @task config
    */
   public function setConduitKey($conduit_key) {
     $this->conduitKey = $conduit_key;
     return $this;
   }
 
 
   /**
    * Get the field key for use in Conduit.
    *
    * @return string Conduit key for this field.
    * @task config
    */
   public function getConduitKey() {
     if ($this->conduitKey !== null) {
       return $this->conduitKey;
     }
 
     return $this->getKey();
   }
 
 
   /**
    * Set a human-readable description for this field.
    *
-   * @param string Human-readable description.
+   * @param string $description Human-readable description.
    * @return this
    * @task config
    */
   public function setDescription($description) {
     $this->description = $description;
     return $this;
   }
 
 
   /**
    * Get this field's human-readable description.
    *
    * @return string|null Human-readable description.
    * @task config
    */
   public function getDescription() {
     return $this->description;
   }
 
 
   /**
    * Hide this field from the web UI.
    *
-   * @param bool True to hide the field from the web UI.
+   * @param bool $is_hidden True to hide the field from the web UI.
    * @return this
    * @task config
    */
   public function setIsHidden($is_hidden) {
     $this->isHidden = $is_hidden;
     return $this;
   }
 
 
   /**
    * Should this field be hidden from the web UI?
    *
    * @return bool True to hide the field in the web UI.
    * @task config
    */
   public function getIsHidden() {
     return $this->isHidden;
   }
 
 
 /* -(  Handling Errors  )---------------------------------------------------- */
 
 
   protected function addError($short, $long) {
     $this->errors[] = array($short, $long);
     return $this;
   }
 
   public function getErrors() {
     return $this->errors;
   }
 
   protected function validateControlValue($value) {
     return;
   }
 
   protected function getShortError() {
     $error = head($this->getErrors());
     if ($error) {
       return head($error);
     }
     return null;
   }
 
 
 /* -(  Reading and Writing Field Values  )----------------------------------- */
 
 
   public function readValueFromRequest(AphrontRequest $request) {
     $check = array_merge(array($this->getKey()), $this->getAliases());
     foreach ($check as $key) {
       if ($this->getValueExistsInRequest($request, $key)) {
         return $this->getValueFromRequest($request, $key);
       }
     }
     return $this->getDefaultValue();
   }
 
   protected function getValueExistsInRequest(AphrontRequest $request, $key) {
     return $request->getExists($key);
   }
 
   abstract protected function getValueFromRequest(
     AphrontRequest $request,
     $key);
 
   public function readValueFromSavedQuery(PhabricatorSavedQuery $saved) {
     $value = $saved->getParameter(
       $this->getKey(),
       $this->getDefaultValue());
     $this->value = $this->didReadValueFromSavedQuery($value);
     $this->validateControlValue($value);
     return $this;
   }
 
   protected function didReadValueFromSavedQuery($value) {
     return $value;
   }
 
   public function getValue() {
     return $this->value;
   }
 
   protected function getValueForControl() {
     return $this->value;
   }
 
   protected function getDefaultValue() {
     return null;
   }
 
   public function getValueForQuery($value) {
     return $value;
   }
 
 
 /* -(  Rendering Controls  )------------------------------------------------- */
 
 
   protected function newControl() {
     throw new PhutilMethodNotImplementedException();
   }
 
 
   protected function renderControl() {
     if ($this->getIsHidden()) {
       return null;
     }
 
     $control = $this->newControl();
 
     if (!$control) {
       return null;
     }
 
     // TODO: We should `setError($this->getShortError())` here, but it looks
     // terrible in the form layout.
 
     return $control
       ->setValue($this->getValueForControl())
       ->setName($this->getKey())
       ->setLabel($this->getLabel());
   }
 
   public function appendToForm(AphrontFormView $form) {
     $control = $this->renderControl();
     if ($control !== null) {
       $form->appendControl($this->renderControl());
     }
     return $this;
   }
 
 
 /* -(  Integration with Conduit  )------------------------------------------- */
 
 
   /**
    * @task conduit
    */
   final public function getConduitParameterType() {
     if (!$this->getEnableForConduit()) {
       return false;
     }
 
     $type = $this->newConduitParameterType();
 
     if ($type) {
       $type->setViewer($this->getViewer());
     }
 
     return $type;
   }
 
   protected function newConduitParameterType() {
     return null;
   }
 
   public function getValueExistsInConduitRequest(array $constraints) {
     return $this->getConduitParameterType()->getExists(
       $constraints,
       $this->getConduitKey());
   }
 
   public function readValueFromConduitRequest(
     array $constraints,
     $strict = true) {
 
     return $this->getConduitParameterType()->getValue(
       $constraints,
       $this->getConduitKey(),
       $strict);
   }
 
   public function getValidConstraintKeys() {
     return $this->getConduitParameterType()->getKeys(
       $this->getConduitKey());
   }
 
   final public function setEnableForConduit($enable) {
     $this->enableForConduit = $enable;
     return $this;
   }
 
   final public function getEnableForConduit() {
     return $this->enableForConduit;
   }
 
   public function newConduitConstants() {
     return array();
   }
 
 
 /* -(  Utility Methods )----------------------------------------------------- */
 
 
   /**
    * Read a list of items from the request, in either array format or string
    * format:
    *
    *   list[]=item1&list[]=item2
    *   list=item1,item2
    *
    * This provides flexibility when constructing URIs, especially from external
    * sources.
    *
-   * @param AphrontRequest  Request to read strings from.
-   * @param string          Key to read in the request.
+   * @param AphrontRequest  $request Request to read strings from.
+   * @param string          $key Key to read in the request.
    * @return list<string>   List of values.
    * @task utility
    */
   protected function getListFromRequest(
     AphrontRequest $request,
     $key) {
 
     $list = $request->getArr($key, null);
     if ($list === null) {
       $list = $request->getStrList($key);
     }
 
     if (!$list) {
       return array();
     }
 
     return $list;
   }
 
 
 
 }
diff --git a/src/applications/search/fulltextstorage/PhabricatorFulltextStorageEngine.php b/src/applications/search/fulltextstorage/PhabricatorFulltextStorageEngine.php
index ba019ea593..3745bbf35e 100644
--- a/src/applications/search/fulltextstorage/PhabricatorFulltextStorageEngine.php
+++ b/src/applications/search/fulltextstorage/PhabricatorFulltextStorageEngine.php
@@ -1,99 +1,99 @@
 <?php
 
 /**
  * Base class for Phabricator search engine providers. Each engine must offer
  * three capabilities: indexing, searching, and reconstruction (this can be
  * stubbed out if an engine can't reasonably do it, it is used for debugging).
  */
 abstract class PhabricatorFulltextStorageEngine extends Phobject {
 
   protected $service;
 
   public function getHosts() {
     return $this->service->getHosts();
   }
 
   public function setService(PhabricatorSearchService $service) {
     $this->service = $service;
     return $this;
   }
 
   /**
    * @return PhabricatorSearchService
    */
   public function getService() {
     return $this->service;
   }
 
   /**
    * Implementations must return a prototype host instance which is cloned
    * by the PhabricatorSearchService infrastructure to configure each engine.
    * @return PhabricatorSearchHost
    */
   abstract public function getHostType();
 
 /* -(  Engine Metadata  )---------------------------------------------------- */
 
   /**
    * Return a unique, nonempty string which identifies this storage engine.
    *
    * @return string Unique string for this engine, max length 32.
    * @task meta
    */
   abstract public function getEngineIdentifier();
 
 /* -(  Managing Documents  )------------------------------------------------- */
 
   /**
    * Update the index for an abstract document.
    *
-   * @param PhabricatorSearchAbstractDocument Document to update.
+   * @param PhabricatorSearchAbstractDocument $document Document to update.
    * @return void
    */
   abstract public function reindexAbstractDocument(
     PhabricatorSearchAbstractDocument $document);
 
   /**
    * Execute a search query.
    *
-   * @param PhabricatorSavedQuery A query to execute.
+   * @param PhabricatorSavedQuery $query A query to execute.
    * @return list A list of matching PHIDs.
    */
   abstract public function executeSearch(PhabricatorSavedQuery $query);
 
   /**
    * Does the search index exist?
    *
    * @return bool
    */
   abstract public function indexExists();
 
   /**
     * Implementations should override this method to return a dictionary of
     * stats which are suitable for display in the admin UI.
     */
   abstract public function getIndexStats();
 
 
   /**
    * Is the index in a usable state?
    *
    * @return bool
    */
   public function indexIsSane() {
     return $this->indexExists();
   }
 
   /**
    * Do any sort of setup for the search index.
    *
    * @return void
    */
   public function initIndex() {}
 
 
   public function getFulltextTokens() {
     return array();
   }
 
 }
diff --git a/src/applications/settings/panel/PhabricatorSettingsPanel.php b/src/applications/settings/panel/PhabricatorSettingsPanel.php
index e2efd92093..ac92a134c6 100644
--- a/src/applications/settings/panel/PhabricatorSettingsPanel.php
+++ b/src/applications/settings/panel/PhabricatorSettingsPanel.php
@@ -1,322 +1,322 @@
 <?php
 
 /**
  * Defines a settings panel. Settings panels appear in the Settings application,
  * and behave like lightweight controllers -- generally, they render some sort
  * of form with options in it, and then update preferences when the user
  * submits the form. By extending this class, you can add new settings
  * panels.
  *
  * @task config   Panel Configuration
  * @task panel    Panel Implementation
  * @task internal Internals
  */
 abstract class PhabricatorSettingsPanel extends Phobject {
 
   private $user;
   private $viewer;
   private $controller;
   private $navigation;
   private $overrideURI;
   private $preferences;
 
   public function setUser(PhabricatorUser $user) {
     $this->user = $user;
     return $this;
   }
 
   public function getUser() {
     return $this->user;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   public function setOverrideURI($override_uri) {
     $this->overrideURI = $override_uri;
     return $this;
   }
 
   final public function setController(PhabricatorController $controller) {
     $this->controller = $controller;
     return $this;
   }
 
   final public function getController() {
     return $this->controller;
   }
 
   final public function setNavigation(AphrontSideNavFilterView $navigation) {
     $this->navigation = $navigation;
     return $this;
   }
 
   final public function getNavigation() {
     return $this->navigation;
   }
 
   public function setPreferences(PhabricatorUserPreferences $preferences) {
     $this->preferences = $preferences;
     return $this;
   }
 
   public function getPreferences() {
     return $this->preferences;
   }
 
   final public static function getAllPanels() {
     $panels = id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getPanelKey')
       ->execute();
     return msortv($panels, 'getPanelOrderVector');
   }
 
   final public static function getAllDisplayPanels() {
     $panels = array();
     $groups = PhabricatorSettingsPanelGroup::getAllPanelGroupsWithPanels();
     foreach ($groups as $group) {
       foreach ($group->getPanels() as $key => $panel) {
         $panels[$key] = $panel;
       }
     }
 
     return $panels;
   }
 
   final public function getPanelGroup() {
     $group_key = $this->getPanelGroupKey();
 
     $groups = PhabricatorSettingsPanelGroup::getAllPanelGroupsWithPanels();
     $group = idx($groups, $group_key);
     if (!$group) {
       throw new Exception(
         pht(
           'No settings panel group with key "%s" exists!',
           $group_key));
     }
 
     return $group;
   }
 
 
 /* -(  Panel Configuration  )------------------------------------------------ */
 
 
   /**
    * Return a unique string used in the URI to identify this panel, like
    * "example".
    *
    * @return string Unique panel identifier (used in URIs).
    * @task config
    */
   public function getPanelKey() {
     return $this->getPhobjectClassConstant('PANELKEY');
   }
 
 
   /**
    * Return a human-readable description of the panel's contents, like
    * "Example Settings".
    *
    * @return string Human-readable panel name.
    * @task config
    */
   abstract public function getPanelName();
 
 
   /**
    * Return an icon for the panel in the menu.
    *
    * @return string Icon identifier.
    * @task config
    */
   public function getPanelMenuIcon() {
     return 'fa-wrench';
   }
 
   /**
    * Return a panel group key constant for this panel.
    *
    * @return const Panel group key.
    * @task config
    */
   abstract public function getPanelGroupKey();
 
 
   /**
    * Return false to prevent this panel from being displayed or used. You can
    * do, e.g., configuration checks here, to determine if the feature your
    * panel controls is unavailable in this install. By default, all panels are
    * enabled.
    *
    * @return bool True if the panel should be shown.
    * @task config
    */
   public function isEnabled() {
     return true;
   }
 
 
   /**
    * Return true if this panel is available to users while editing their own
    * settings.
    *
    * @return bool True to enable management on behalf of a user.
    * @task config
    */
   public function isUserPanel() {
     return true;
   }
 
 
   /**
    * Return true if this panel is available to administrators while managing
    * bot and mailing list accounts.
    *
    * @return bool True to enable management on behalf of accounts.
    * @task config
    */
   public function isManagementPanel() {
     return false;
   }
 
 
   /**
    * Return true if this panel is available while editing settings templates.
    *
    * @return bool True to allow editing in templates.
    * @task config
    */
   public function isTemplatePanel() {
     return false;
   }
 
   /**
    * Return true if this panel should be available when enrolling in MFA on
    * a new account with MFA requiredd.
    *
    * @return bool True to allow configuration during MFA enrollment.
    * @task config
    */
   public function isMultiFactorEnrollmentPanel() {
     return false;
   }
 
 
 /* -(  Panel Implementation  )----------------------------------------------- */
 
 
   /**
    * Process a user request for this settings panel. Implement this method like
    * a lightweight controller. If you return an @{class:AphrontResponse}, the
    * response will be used in whole. If you return anything else, it will be
    * treated as a view and composed into a normal settings page.
    *
    * Generally, render your settings panel by returning a form, then return
    * a redirect when the user saves settings.
    *
-   * @param   AphrontRequest  Incoming request.
+   * @param   AphrontRequest  $request Incoming request.
    * @return  wild            Response to request, either as an
    *                          @{class:AphrontResponse} or something which can
    *                          be composed into a @{class:AphrontView}.
    * @task panel
    */
   abstract public function processRequest(AphrontRequest $request);
 
 
   /**
    * Get the URI for this panel.
    *
-   * @param string? Optional path to append.
+   * @param string? $path Optional path to append.
    * @return string Relative URI for the panel.
    * @task panel
    */
   final public function getPanelURI($path = '') {
     $path = ltrim($path, '/');
 
     if ($this->overrideURI) {
       return rtrim($this->overrideURI, '/').'/'.$path;
     }
 
     $key = $this->getPanelKey();
     $key = phutil_escape_uri($key);
 
     $user = $this->getUser();
     if ($user) {
       if ($user->isLoggedIn()) {
         $username = $user->getUsername();
         return "/settings/user/{$username}/page/{$key}/{$path}";
       } else {
         // For logged-out users, we can't put their username in the URI. This
         // page will prompt them to login, then redirect them to the correct
         // location.
         return "/settings/panel/{$key}/";
       }
     } else {
       $builtin = $this->getPreferences()->getBuiltinKey();
       return "/settings/builtin/{$builtin}/page/{$key}/{$path}";
     }
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * Generates a key to sort the list of panels.
    *
    * @return string Sortable key.
    * @task internal
    */
   final public function getPanelOrderVector() {
     return id(new PhutilSortVector())
       ->addString($this->getPanelName());
   }
 
   protected function newDialog() {
     return $this->getController()->newDialog();
   }
 
   protected function writeSetting(
     PhabricatorUserPreferences $preferences,
     $key,
     $value) {
     $viewer = $this->getViewer();
     $request = $this->getController()->getRequest();
 
     $editor = id(new PhabricatorUserPreferencesEditor())
       ->setActor($viewer)
       ->setContentSourceFromRequest($request)
       ->setContinueOnNoEffect(true)
       ->setContinueOnMissingFields(true);
 
     $xactions = array();
     $xactions[] = $preferences->newTransaction($key, $value);
     $editor->applyTransactions($preferences, $xactions);
   }
 
 
   public function newBox($title, $content, $actions = array()) {
     $header = id(new PHUIHeaderView())
       ->setHeader($title);
 
     foreach ($actions as $action) {
       $header->addActionLink($action);
     }
 
     $view = id(new PHUIObjectBoxView())
       ->setHeader($header)
       ->appendChild($content)
       ->setBackground(PHUIObjectBoxView::WHITE_CONFIG);
 
     return $view;
   }
 
 }
diff --git a/src/applications/settings/query/PhabricatorUserPreferencesQuery.php b/src/applications/settings/query/PhabricatorUserPreferencesQuery.php
index 5fa9b89bdd..5795b78f27 100644
--- a/src/applications/settings/query/PhabricatorUserPreferencesQuery.php
+++ b/src/applications/settings/query/PhabricatorUserPreferencesQuery.php
@@ -1,196 +1,197 @@
 <?php
 
 final class PhabricatorUserPreferencesQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   private $ids;
   private $phids;
   private $userPHIDs;
   private $builtinKeys;
   private $hasUserPHID;
   private $users = array();
   private $synthetic;
 
   public function withIDs(array $ids) {
     $this->ids = $ids;
     return $this;
   }
 
   public function withPHIDs(array $phids) {
     $this->phids = $phids;
     return $this;
   }
 
   public function withHasUserPHID($is_user) {
     $this->hasUserPHID = $is_user;
     return $this;
   }
 
   public function withUserPHIDs(array $phids) {
     $this->userPHIDs = $phids;
     return $this;
   }
 
   public function withUsers(array $users) {
     assert_instances_of($users, 'PhabricatorUser');
     $this->users = mpull($users, null, 'getPHID');
     $this->withUserPHIDs(array_keys($this->users));
     return $this;
   }
 
   public function withBuiltinKeys(array $keys) {
     $this->builtinKeys = $keys;
     return $this;
   }
 
   /**
    * Always return preferences for every queried user.
    *
    * If no settings exist for a user, a new empty settings object with
    * appropriate defaults is returned.
    *
-   * @param bool True to generate synthetic preferences for missing users.
+   * @param bool $synthetic True to generate synthetic preferences for missing
+   *   users.
    */
   public function needSyntheticPreferences($synthetic) {
     $this->synthetic = $synthetic;
     return $this;
   }
 
   public function newResultObject() {
     return new PhabricatorUserPreferences();
   }
 
   protected function loadPage() {
     $preferences = $this->loadStandardPage($this->newResultObject());
 
     if ($this->synthetic) {
       $user_map = mpull($preferences, null, 'getUserPHID');
       foreach ($this->userPHIDs as $user_phid) {
         if (isset($user_map[$user_phid])) {
           continue;
         }
         $preferences[] = $this->newResultObject()
           ->setUserPHID($user_phid);
       }
     }
 
     return $preferences;
   }
 
   protected function willFilterPage(array $prefs) {
     $user_phids = mpull($prefs, 'getUserPHID');
     $user_phids = array_filter($user_phids);
 
     // If some of the preferences are attached to users, try to use any objects
     // we were handed first. If we're missing some, load them.
 
     if ($user_phids) {
       $users = $this->users;
 
       $user_phids = array_fuse($user_phids);
       $load_phids = array_diff_key($user_phids, $users);
       $load_phids = array_keys($load_phids);
 
       if ($load_phids) {
         $load_users = id(new PhabricatorPeopleQuery())
           ->setViewer($this->getViewer())
           ->withPHIDs($load_phids)
           ->execute();
         $load_users = mpull($load_users, null, 'getPHID');
         $users += $load_users;
       }
     } else {
       $users = array();
     }
 
     $need_global = array();
     foreach ($prefs as $key => $pref) {
       $user_phid = $pref->getUserPHID();
       if (!$user_phid) {
         $pref->attachUser(null);
         continue;
       }
 
       $need_global[] = $pref;
 
       $user = idx($users, $user_phid);
       if (!$user) {
         $this->didRejectResult($pref);
         unset($prefs[$key]);
         continue;
       }
 
       $pref->attachUser($user);
     }
 
     // If we loaded any user preferences, load the global defaults and attach
     // them if they exist.
     if ($need_global) {
       $global = id(new self())
         ->setViewer($this->getViewer())
         ->withBuiltinKeys(
           array(
             PhabricatorUserPreferences::BUILTIN_GLOBAL_DEFAULT,
           ))
         ->executeOne();
       if ($global) {
         foreach ($need_global as $pref) {
           $pref->attachDefaultSettings($global);
         }
       }
     }
 
     return $prefs;
   }
 
   protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
     $where = parent::buildWhereClauseParts($conn);
 
     if ($this->ids !== null) {
       $where[] = qsprintf(
         $conn,
         'id IN (%Ld)',
         $this->ids);
     }
 
     if ($this->phids !== null) {
       $where[] = qsprintf(
         $conn,
         'phid IN (%Ls)',
         $this->phids);
     }
 
     if ($this->userPHIDs !== null) {
       $where[] = qsprintf(
         $conn,
         'userPHID IN (%Ls)',
         $this->userPHIDs);
     }
 
     if ($this->builtinKeys !== null) {
       $where[] = qsprintf(
         $conn,
         'builtinKey IN (%Ls)',
         $this->builtinKeys);
     }
 
     if ($this->hasUserPHID !== null) {
       if ($this->hasUserPHID) {
         $where[] = qsprintf(
           $conn,
           'userPHID IS NOT NULL');
       } else {
         $where[] = qsprintf(
           $conn,
           'userPHID IS NULL');
       }
     }
 
     return $where;
   }
 
   public function getQueryApplicationClass() {
     return PhabricatorSettingsApplication::class;
   }
 
 }
diff --git a/src/applications/settings/storage/PhabricatorUserPreferences.php b/src/applications/settings/storage/PhabricatorUserPreferences.php
index 6cceff57de..f004361a29 100644
--- a/src/applications/settings/storage/PhabricatorUserPreferences.php
+++ b/src/applications/settings/storage/PhabricatorUserPreferences.php
@@ -1,256 +1,256 @@
 <?php
 
 final class PhabricatorUserPreferences
   extends PhabricatorUserDAO
   implements
     PhabricatorPolicyInterface,
     PhabricatorDestructibleInterface,
     PhabricatorApplicationTransactionInterface {
 
   const BUILTIN_GLOBAL_DEFAULT = 'global';
 
   protected $userPHID;
   protected $preferences = array();
   protected $builtinKey;
 
   private $user = self::ATTACHABLE;
   private $defaultSettings;
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_SERIALIZATION => array(
         'preferences' => self::SERIALIZATION_JSON,
       ),
       self::CONFIG_COLUMN_SCHEMA => array(
         'userPHID' => 'phid?',
         'builtinKey' => 'text32?',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_user' => array(
           'columns' => array('userPHID'),
           'unique' => true,
         ),
         'key_builtin' => array(
           'columns' => array('builtinKey'),
           'unique' => true,
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function generatePHID() {
     return PhabricatorPHID::generateNewPHID(
       PhabricatorUserPreferencesPHIDType::TYPECONST);
   }
 
   public function getPreference($key, $default = null) {
     return idx($this->preferences, $key, $default);
   }
 
   public function setPreference($key, $value) {
     $this->preferences[$key] = $value;
     return $this;
   }
 
   public function unsetPreference($key) {
     unset($this->preferences[$key]);
     return $this;
   }
 
   public function getDefaultValue($key) {
     if ($this->defaultSettings) {
       return $this->defaultSettings->getSettingValue($key);
     }
 
     $setting = self::getSettingObject($key);
 
     if (!$setting) {
       return null;
     }
 
     $setting = id(clone $setting)
       ->setViewer($this->getUser());
 
     return $setting->getSettingDefaultValue();
   }
 
   public function getSettingValue($key) {
     if (array_key_exists($key, $this->preferences)) {
       return $this->preferences[$key];
     }
 
     return $this->getDefaultValue($key);
   }
 
   private static function getSettingObject($key) {
     $settings = PhabricatorSetting::getAllSettings();
     return idx($settings, $key);
   }
 
   public function attachDefaultSettings(PhabricatorUserPreferences $settings) {
     $this->defaultSettings = $settings;
     return $this;
   }
 
   public function attachUser(PhabricatorUser $user = null) {
     $this->user = $user;
     return $this;
   }
 
   public function getUser() {
     return $this->assertAttached($this->user);
   }
 
   public function hasManagedUser() {
     $user_phid = $this->getUserPHID();
     if (!$user_phid) {
       return false;
     }
 
     $user = $this->getUser();
     if ($user->getIsSystemAgent() || $user->getIsMailingList()) {
       return true;
     }
 
     return false;
   }
 
   /**
    * Load or create a preferences object for the given user.
    *
-   * @param PhabricatorUser User to load or create preferences for.
+   * @param PhabricatorUser $user User to load or create preferences for.
    */
   public static function loadUserPreferences(PhabricatorUser $user) {
     return id(new PhabricatorUserPreferencesQuery())
       ->setViewer($user)
       ->withUsers(array($user))
       ->needSyntheticPreferences(true)
       ->executeOne();
   }
 
   /**
    * Load or create a global preferences object.
    *
    * If no global preferences exist, an empty preferences object is returned.
    *
-   * @param PhabricatorUser Viewing user.
+   * @param PhabricatorUser $viewer Viewing user.
    */
   public static function loadGlobalPreferences(PhabricatorUser $viewer) {
     $global = id(new PhabricatorUserPreferencesQuery())
       ->setViewer($viewer)
       ->withBuiltinKeys(
         array(
           self::BUILTIN_GLOBAL_DEFAULT,
         ))
       ->executeOne();
 
     if (!$global) {
       $global = id(new self())
         ->attachUser(new PhabricatorUser());
     }
 
     return $global;
   }
 
   public function newTransaction($key, $value) {
     $setting_property = PhabricatorUserPreferencesTransaction::PROPERTY_SETTING;
     $xaction_type = PhabricatorUserPreferencesTransaction::TYPE_SETTING;
 
     return id(clone $this->getApplicationTransactionTemplate())
       ->setTransactionType($xaction_type)
       ->setMetadataValue($setting_property, $key)
       ->setNewValue($value);
   }
 
   public function getEditURI() {
     if ($this->getUser()) {
       return '/settings/user/'.$this->getUser()->getUsername().'/';
     } else {
       return '/settings/builtin/'.$this->getBuiltinKey().'/';
     }
   }
 
   public function getDisplayName() {
     if ($this->getBuiltinKey()) {
       return pht('Global Default Settings');
     }
 
     return pht('Personal Settings');
   }
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         $user_phid = $this->getUserPHID();
         if ($user_phid) {
           return $user_phid;
         }
 
         return PhabricatorPolicies::getMostOpenPolicy();
       case PhabricatorPolicyCapability::CAN_EDIT:
         if ($this->hasManagedUser()) {
           return PhabricatorPolicies::POLICY_ADMIN;
         }
 
         $user_phid = $this->getUserPHID();
         if ($user_phid) {
           return $user_phid;
         }
 
         return PhabricatorPolicies::POLICY_ADMIN;
     }
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     if ($this->hasManagedUser()) {
       if ($viewer->getIsAdmin()) {
         return true;
       }
     }
 
     $builtin_key = $this->getBuiltinKey();
 
     $is_global = ($builtin_key === self::BUILTIN_GLOBAL_DEFAULT);
     $is_view = ($capability === PhabricatorPolicyCapability::CAN_VIEW);
 
     if ($is_global && $is_view) {
       // NOTE: Without this policy exception, the logged-out viewer can not
       // see global preferences.
       return true;
     }
 
     return false;
   }
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
     $this->delete();
   }
 
 
 /* -(  PhabricatorApplicationTransactionInterface  )------------------------- */
 
 
   public function getApplicationTransactionEditor() {
     return new PhabricatorUserPreferencesEditor();
   }
 
   public function getApplicationTransactionTemplate() {
     return new PhabricatorUserPreferencesTransaction();
   }
 
 }
diff --git a/src/applications/spaces/query/PhabricatorSpacesNamespaceQuery.php b/src/applications/spaces/query/PhabricatorSpacesNamespaceQuery.php
index 6703ed664e..6de31ff5c1 100644
--- a/src/applications/spaces/query/PhabricatorSpacesNamespaceQuery.php
+++ b/src/applications/spaces/query/PhabricatorSpacesNamespaceQuery.php
@@ -1,238 +1,238 @@
 <?php
 
 final class PhabricatorSpacesNamespaceQuery
   extends PhabricatorCursorPagedPolicyAwareQuery {
 
   const KEY_ALL = 'spaces.all';
   const KEY_DEFAULT = 'spaces.default';
   const KEY_VIEWER = 'spaces.viewer';
 
   private $ids;
   private $phids;
   private $isDefaultNamespace;
   private $isArchived;
 
   public function withIDs(array $ids) {
     $this->ids = $ids;
     return $this;
   }
 
   public function withPHIDs(array $phids) {
     $this->phids = $phids;
     return $this;
   }
 
   public function withIsDefaultNamespace($default) {
     $this->isDefaultNamespace = $default;
     return $this;
   }
 
   public function withIsArchived($archived) {
     $this->isArchived = $archived;
     return $this;
   }
 
   public function getQueryApplicationClass() {
     return PhabricatorSpacesApplication::class;
   }
 
   public function newResultObject() {
     return new PhabricatorSpacesNamespace();
   }
 
   protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
     $where = parent::buildWhereClauseParts($conn);
 
     if ($this->ids !== null) {
       $where[] = qsprintf(
         $conn,
         'id IN (%Ld)',
         $this->ids);
     }
 
     if ($this->phids !== null) {
       $where[] = qsprintf(
         $conn,
         'phid IN (%Ls)',
         $this->phids);
     }
 
     if ($this->isDefaultNamespace !== null) {
       if ($this->isDefaultNamespace) {
         $where[] = qsprintf(
           $conn,
           'isDefaultNamespace = 1');
       } else {
         $where[] = qsprintf(
           $conn,
           'isDefaultNamespace IS NULL');
       }
     }
 
     if ($this->isArchived !== null) {
       $where[] = qsprintf(
         $conn,
         'isArchived = %d',
         (int)$this->isArchived);
     }
 
     return $where;
   }
 
   public static function destroySpacesCache() {
     $cache = PhabricatorCaches::getRequestCache();
     $cache->deleteKeys(
       array(
         self::KEY_ALL,
         self::KEY_DEFAULT,
       ));
   }
 
   public static function getSpacesExist() {
     return (bool)self::getAllSpaces();
   }
 
   public static function getViewerSpacesExist(PhabricatorUser $viewer) {
     if (!self::getSpacesExist()) {
       return false;
     }
 
     // If the viewer has access to only one space, pretend spaces simply don't
     // exist.
     $spaces = self::getViewerSpaces($viewer);
     return (count($spaces) > 1);
   }
 
   public static function getAllSpaces() {
     $cache = PhabricatorCaches::getRequestCache();
     $cache_key = self::KEY_ALL;
 
     $spaces = $cache->getKey($cache_key);
     if ($spaces === null) {
       $spaces = id(new PhabricatorSpacesNamespaceQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->execute();
       $spaces = mpull($spaces, null, 'getPHID');
       $cache->setKey($cache_key, $spaces);
     }
 
     return $spaces;
   }
 
   public static function getDefaultSpace() {
     $cache = PhabricatorCaches::getRequestCache();
     $cache_key = self::KEY_DEFAULT;
 
     $default_space = $cache->getKey($cache_key, false);
     if ($default_space === false) {
       $default_space = null;
 
       $spaces = self::getAllSpaces();
       foreach ($spaces as $space) {
         if ($space->getIsDefaultNamespace()) {
           $default_space = $space;
           break;
         }
       }
 
       $cache->setKey($cache_key, $default_space);
     }
 
     return $default_space;
   }
 
   public static function getViewerSpaces(PhabricatorUser $viewer) {
     $cache = PhabricatorCaches::getRequestCache();
     $cache_key = self::KEY_VIEWER.'('.$viewer->getCacheFragment().')';
 
     $result = $cache->getKey($cache_key);
     if ($result === null) {
       $spaces = self::getAllSpaces();
 
       $result = array();
       foreach ($spaces as $key => $space) {
         $can_see = PhabricatorPolicyFilter::hasCapability(
           $viewer,
           $space,
           PhabricatorPolicyCapability::CAN_VIEW);
         if ($can_see) {
           $result[$key] = $space;
         }
       }
 
       $cache->setKey($cache_key, $result);
     }
 
     return $result;
   }
 
 
   public static function getViewerActiveSpaces(PhabricatorUser $viewer) {
     $spaces = self::getViewerSpaces($viewer);
 
     foreach ($spaces as $key => $space) {
       if ($space->getIsArchived()) {
         unset($spaces[$key]);
       }
     }
 
     return $spaces;
   }
 
   public static function getSpaceOptionsForViewer(
     PhabricatorUser $viewer,
     $space_phid) {
 
     $viewer_spaces = self::getViewerSpaces($viewer);
     $viewer_spaces = msort($viewer_spaces, 'getNamespaceName');
 
     $map = array();
     foreach ($viewer_spaces as $space) {
 
       // Skip archived spaces, unless the object is already in that space.
       if ($space->getIsArchived()) {
         if ($space->getPHID() != $space_phid) {
           continue;
         }
       }
 
       $map[$space->getPHID()] = pht(
         'Space %s: %s',
         $space->getMonogram(),
         $space->getNamespaceName());
     }
 
     return $map;
   }
 
 
   /**
    * Get the Space PHID for an object, if one exists.
    *
    * This is intended to simplify performing a bunch of redundant checks; you
    * can intentionally pass any value in (including `null`).
    *
-   * @param wild
+   * @param wild $object
    * @return phid|null
    */
   public static function getObjectSpacePHID($object) {
     if (!$object) {
       return null;
     }
 
     if (!($object instanceof PhabricatorSpacesInterface)) {
       return null;
     }
 
     $space_phid = $object->getSpacePHID();
     if ($space_phid === null) {
       $default_space = self::getDefaultSpace();
       if ($default_space) {
         $space_phid = $default_space->getPHID();
       }
     }
 
     return $space_phid;
   }
 
 }
diff --git a/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php b/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php
index 3ee4b5f6e8..a36fed1c5d 100644
--- a/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php
+++ b/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php
@@ -1,102 +1,102 @@
 <?php
 
 final class PhabricatorSubscriptionsEditor extends PhabricatorEditor {
 
   private $object;
 
   private $explicitSubscribePHIDs = array();
   private $implicitSubscribePHIDs = array();
   private $unsubscribePHIDs       = array();
 
   public function setObject(PhabricatorSubscribableInterface $object) {
     $this->object = $object;
     return $this;
   }
 
   /**
    * Add explicit subscribers. These subscribers have explicitly subscribed
    * (or been subscribed) to the object, and will be added even if they
    * had previously unsubscribed.
    *
-   * @param list<phid>  List of PHIDs to explicitly subscribe.
+   * @param list<phid> $phids List of PHIDs to explicitly subscribe.
    * @return this
    */
   public function subscribeExplicit(array $phids) {
     $this->explicitSubscribePHIDs += array_fill_keys($phids, true);
     return $this;
   }
 
 
   /**
    * Add implicit subscribers. These subscribers have taken some action which
    * implicitly subscribes them (e.g., adding a comment) but it will be
    * suppressed if they've previously unsubscribed from the object.
    *
-   * @param list<phid>  List of PHIDs to implicitly subscribe.
+   * @param list<phid> $phids List of PHIDs to implicitly subscribe.
    * @return this
    */
   public function subscribeImplicit(array $phids) {
     $this->implicitSubscribePHIDs += array_fill_keys($phids, true);
     return $this;
   }
 
 
   /**
    * Unsubscribe PHIDs and mark them as unsubscribed, so implicit subscriptions
    * will not resubscribe them.
    *
-   * @param list<phid>  List of PHIDs to unsubscribe.
+   * @param list<phid> $phids List of PHIDs to unsubscribe.
    * @return this
    */
   public function unsubscribe(array $phids) {
     $this->unsubscribePHIDs += array_fill_keys($phids, true);
     return $this;
   }
 
 
   public function save() {
     if (!$this->object) {
       throw new PhutilInvalidStateException('setObject');
     }
     $actor = $this->requireActor();
 
     $src = $this->object->getPHID();
 
     if ($this->implicitSubscribePHIDs) {
       $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
         $src,
         PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST);
       $unsub = array_fill_keys($unsub, true);
       $this->implicitSubscribePHIDs = array_diff_key(
         $this->implicitSubscribePHIDs,
         $unsub);
     }
 
     $add = $this->implicitSubscribePHIDs + $this->explicitSubscribePHIDs;
     $del = $this->unsubscribePHIDs;
 
     // If a PHID is marked for both subscription and unsubscription, treat
     // unsubscription as the stronger action.
     $add = array_diff_key($add, $del);
 
     if ($add || $del) {
       $u_type = PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST;
       $s_type = PhabricatorObjectHasSubscriberEdgeType::EDGECONST;
 
       $editor = new PhabricatorEdgeEditor();
 
       foreach ($add as $phid => $ignored) {
         $editor->removeEdge($src, $u_type, $phid);
         $editor->addEdge($src, $s_type, $phid);
       }
 
       foreach ($del as $phid => $ignored) {
         $editor->removeEdge($src, $s_type, $phid);
         $editor->addEdge($src, $u_type, $phid);
       }
 
       $editor->save();
     }
   }
 
 }
diff --git a/src/applications/subscriptions/interface/PhabricatorSubscribableInterface.php b/src/applications/subscriptions/interface/PhabricatorSubscribableInterface.php
index 1af9e7107f..6528774ee8 100644
--- a/src/applications/subscriptions/interface/PhabricatorSubscribableInterface.php
+++ b/src/applications/subscriptions/interface/PhabricatorSubscribableInterface.php
@@ -1,27 +1,28 @@
 <?php
 
 interface PhabricatorSubscribableInterface {
 
   /**
    * Return true to indicate that the given PHID is automatically subscribed
    * to the object (for example, they are the author or in some other way
    * irrevocably a subscriber). This will, e.g., cause the UI to render
    * "Automatically Subscribed" instead of "Subscribe".
    *
-   * @param PHID  PHID (presumably a user) to test for automatic subscription.
+   * @param PHID $phid PHID (presumably a user) to test for automatic
+   *   subscription.
    * @return bool True if the object/user is automatically subscribed.
    */
   public function isAutomaticallySubscribed($phid);
 
 }
 
 // TEMPLATE IMPLEMENTATION /////////////////////////////////////////////////////
 
 /* -(  PhabricatorSubscribableInterface  )----------------------------------- */
 /*
 
   public function isAutomaticallySubscribed($phid) {
     return false;
   }
 
 */
diff --git a/src/applications/system/engine/PhabricatorSystemActionEngine.php b/src/applications/system/engine/PhabricatorSystemActionEngine.php
index 6d6f9eacfd..106ef05e48 100644
--- a/src/applications/system/engine/PhabricatorSystemActionEngine.php
+++ b/src/applications/system/engine/PhabricatorSystemActionEngine.php
@@ -1,207 +1,207 @@
 <?php
 
 final class PhabricatorSystemActionEngine extends Phobject {
 
   /**
    * Prepare to take an action, throwing an exception if the user has exceeded
    * the rate limit.
    *
    * The `$actors` are a list of strings. Normally this will be a list of
    * user PHIDs, but some systems use other identifiers (like email
    * addresses). Each actor's score threshold is tracked independently. If
    * any actor exceeds the rate limit for the action, this method throws.
    *
    * The `$action` defines the actual thing being rate limited, and sets the
    * limit.
    *
    * You can pass either a positive, zero, or negative `$score` to this method:
    *
    *   - If the score is positive, the user is given that many points toward
    *     the rate limit after the limit is checked. Over time, this will cause
    *     them to hit the rate limit and be prevented from taking further
    *     actions.
    *   - If the score is zero, the rate limit is checked but no score changes
    *     are made. This allows you to check for a rate limit before beginning
    *     a workflow, so the user doesn't fill in a form only to get rate limited
    *     at the end.
    *   - If the score is negative, the user is credited points, allowing them
    *     to take more actions than the limit normally permits. By awarding
    *     points for failed actions and credits for successful actions, a
    *     system can be sensitive to failure without overly restricting
    *     legitimate uses.
    *
    * If any actor is exceeding their rate limit, this method throws a
    * @{class:PhabricatorSystemActionRateLimitException}.
    *
-   * @param list<string> List of actors.
-   * @param PhabricatorSystemAction Action being taken.
-   * @param float Score or credit, see above.
+   * @param list<string> $actors List of actors.
+   * @param PhabricatorSystemAction $action Action being taken.
+   * @param float $score Score or credit, see above.
    * @return void
    */
   public static function willTakeAction(
     array $actors,
     PhabricatorSystemAction $action,
     $score) {
 
     // If the score for this action is negative, we're giving the user a credit,
     // so don't bother checking if they're blocked or not.
     if ($score >= 0) {
       $blocked = self::loadBlockedActors($actors, $action, $score);
       if ($blocked) {
         foreach ($blocked as $actor => $actor_score) {
           throw new PhabricatorSystemActionRateLimitException(
             $action,
             $actor_score);
         }
       }
     }
 
     if ($score != 0) {
       $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
         self::recordAction($actors, $action, $score);
       unset($unguarded);
     }
   }
 
   public static function loadBlockedActors(
     array $actors,
     PhabricatorSystemAction $action,
     $score) {
 
     $scores = self::loadScores($actors, $action);
     $window = self::getWindow();
 
     $blocked = array();
     foreach ($scores as $actor => $actor_score) {
       // For the purposes of checking for a block, we just use the raw
       // persistent score and do not include the score for this action. This
       // allows callers to test for a block without adding any points and get
       // the same result they would if they were adding points: we only
       // trigger a rate limit when the persistent score exceeds the threshold.
       if ($action->shouldBlockActor($actor, $actor_score)) {
         // When reporting the results, we do include the points for this
         // action. This makes the error messages more clear, since they
         // more accurately report the number of actions the user has really
         // tried to take.
         $blocked[$actor] = $actor_score + ($score / $window);
       }
     }
 
     return $blocked;
   }
 
   public static function loadScores(
     array $actors,
     PhabricatorSystemAction $action) {
 
     if (!$actors) {
       return array();
     }
 
     $actor_hashes = array();
     foreach ($actors as $actor) {
       $digest = PhabricatorHash::digestForIndex($actor);
       $actor_hashes[$digest] = $actor;
     }
 
     $log = new PhabricatorSystemActionLog();
 
     $window = self::getWindow();
 
     $conn = $log->establishConnection('r');
 
     $rows = queryfx_all(
       $conn,
       'SELECT actorHash, SUM(score) totalScore FROM %T
         WHERE action = %s AND actorHash IN (%Ls)
           AND epoch >= %d GROUP BY actorHash',
       $log->getTableName(),
       $action->getActionConstant(),
       array_keys($actor_hashes),
       (PhabricatorTime::getNow() - $window));
 
     $rows = ipull($rows, 'totalScore', 'actorHash');
 
     $scores = array();
     foreach ($actor_hashes as $digest => $actor) {
       $score = idx($rows, $digest, 0);
       $scores[$actor] = ($score / $window);
     }
 
     return $scores;
   }
 
   private static function recordAction(
     array $actors,
     PhabricatorSystemAction $action,
     $score) {
 
     $log = new PhabricatorSystemActionLog();
     $conn_w = $log->establishConnection('w');
 
     $sql = array();
     foreach ($actors as $actor) {
       $sql[] = qsprintf(
         $conn_w,
         '(%s, %s, %s, %f, %d)',
         PhabricatorHash::digestForIndex($actor),
         $actor,
         $action->getActionConstant(),
         $score,
         time());
     }
 
     foreach (PhabricatorLiskDAO::chunkSQL($sql) as $chunk) {
       queryfx(
         $conn_w,
         'INSERT INTO %T (actorHash, actorIdentity, action, score, epoch)
           VALUES %LQ',
         $log->getTableName(),
         $chunk);
     }
   }
 
   private static function getWindow() {
     // Limit queries to the last hour of data so we don't need to look at as
     // many rows. We can use an arbitrarily larger window instead (we normalize
     // scores to actions per second) but all the actions we care about limiting
     // have a limit much higher than one action per hour.
     return phutil_units('1 hour in seconds');
   }
 
 
   /**
    * Reset all action counts for actions taken by some set of actors in the
    * previous action window.
    *
-   * @param list<string> Actors to reset counts for.
+   * @param list<string> $actors Actors to reset counts for.
    * @return int Number of actions cleared.
    */
   public static function resetActions(array $actors) {
     $log = new PhabricatorSystemActionLog();
     $conn_w = $log->establishConnection('w');
 
     $now = PhabricatorTime::getNow();
 
     $hashes = array();
     foreach ($actors as $actor) {
       $hashes[] = PhabricatorHash::digestForIndex($actor);
     }
 
     queryfx(
       $conn_w,
       'DELETE FROM %T
         WHERE actorHash IN (%Ls) AND epoch BETWEEN %d AND %d',
       $log->getTableName(),
       $hashes,
       $now - self::getWindow(),
       $now);
 
     return $conn_w->getAffectedRows();
   }
 
   public static function newActorFromRequest(AphrontRequest $request) {
     return $request->getRemoteAddress();
   }
 
 }
diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php
index 4cc9914fe2..6b3aae2790 100644
--- a/src/applications/transactions/editengine/PhabricatorEditEngine.php
+++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php
@@ -1,2765 +1,2765 @@
 <?php
 
 
 /**
  * @task fields Managing Fields
  * @task text Display Text
  * @task config Edit Engine Configuration
  * @task uri Managing URIs
  * @task load Creating and Loading Objects
  * @task web Responding to Web Requests
  * @task edit Responding to Edit Requests
  * @task http Responding to HTTP Parameter Requests
  * @task conduit Responding to Conduit Requests
  */
 abstract class PhabricatorEditEngine
   extends Phobject
   implements PhabricatorPolicyInterface {
 
   const EDITENGINECONFIG_DEFAULT = 'default';
 
   const SUBTYPE_DEFAULT = 'default';
 
   private $viewer;
   private $controller;
   private $isCreate;
   private $editEngineConfiguration;
   private $contextParameters = array();
   private $targetObject;
   private $page;
   private $pages;
   private $navigation;
 
   final public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   final public function getViewer() {
     return $this->viewer;
   }
 
   final public function setController(PhabricatorController $controller) {
     $this->controller = $controller;
     $this->setViewer($controller->getViewer());
     return $this;
   }
 
   final public function getController() {
     return $this->controller;
   }
 
   final public function getEngineKey() {
     $key = $this->getPhobjectClassConstant('ENGINECONST', 64);
     if (strpos($key, '/') !== false) {
       throw new Exception(
         pht(
           'EditEngine ("%s") contains an invalid key character "/".',
           get_class($this)));
     }
     return $key;
   }
 
   final public function getApplication() {
     $app_class = $this->getEngineApplicationClass();
     return PhabricatorApplication::getByClass($app_class);
   }
 
   final public function addContextParameter($key) {
     $this->contextParameters[] = $key;
     return $this;
   }
 
   public function isEngineConfigurable() {
     return true;
   }
 
   public function isEngineExtensible() {
     return true;
   }
 
   public function isDefaultQuickCreateEngine() {
     return false;
   }
 
   public function getDefaultQuickCreateFormKeys() {
     $keys = array();
 
     if ($this->isDefaultQuickCreateEngine()) {
       $keys[] = self::EDITENGINECONFIG_DEFAULT;
     }
 
     foreach ($keys as $idx => $key) {
       $keys[$idx] = $this->getEngineKey().'/'.$key;
     }
 
     return $keys;
   }
 
   public static function splitFullKey($full_key) {
     return explode('/', $full_key, 2);
   }
 
   public function getQuickCreateOrderVector() {
     return id(new PhutilSortVector())
       ->addString($this->getObjectCreateShortText());
   }
 
   /**
    * Force the engine to edit a particular object.
    */
   public function setTargetObject($target_object) {
     $this->targetObject = $target_object;
     return $this;
   }
 
   public function getTargetObject() {
     return $this->targetObject;
   }
 
   public function setNavigation(AphrontSideNavFilterView $navigation) {
     $this->navigation = $navigation;
     return $this;
   }
 
   public function getNavigation() {
     return $this->navigation;
   }
 
 
 /* -(  Managing Fields  )---------------------------------------------------- */
 
 
   abstract public function getEngineApplicationClass();
   abstract protected function buildCustomEditFields($object);
 
   public function getFieldsForConfig(
     PhabricatorEditEngineConfiguration $config) {
 
     $object = $this->newEditableObject();
 
     $this->editEngineConfiguration = $config;
 
     // This is mostly making sure that we fill in default values.
     $this->setIsCreate(true);
 
     return $this->buildEditFields($object);
   }
 
   final protected function buildEditFields($object) {
     $viewer = $this->getViewer();
 
     $fields = $this->buildCustomEditFields($object);
 
     foreach ($fields as $field) {
       $field
         ->setViewer($viewer)
         ->setObject($object);
     }
 
     $fields = mpull($fields, null, 'getKey');
 
     if ($this->isEngineExtensible()) {
       $extensions = PhabricatorEditEngineExtension::getAllEnabledExtensions();
     } else {
       $extensions = array();
     }
 
     // See T13248. Create a template object to provide to extensions. We
     // adjust the template to have the intended subtype, so that extensions
     // may change behavior based on the form subtype.
 
     $template_object = clone $object;
     if ($this->getIsCreate()) {
       if ($this->supportsSubtypes()) {
         $config = $this->getEditEngineConfiguration();
         $subtype = $config->getSubtype();
         $template_object->setSubtype($subtype);
       }
     }
 
     foreach ($extensions as $extension) {
       $extension->setViewer($viewer);
 
       if (!$extension->supportsObject($this, $template_object)) {
         continue;
       }
 
       $extension_fields = $extension->buildCustomEditFields(
         $this,
         $template_object);
 
       // TODO: Validate this in more detail with a more tailored error.
       assert_instances_of($extension_fields, 'PhabricatorEditField');
 
       foreach ($extension_fields as $field) {
         $field
           ->setViewer($viewer)
           ->setObject($object);
 
         $group_key = $field->getBulkEditGroupKey();
         if ($group_key === null) {
           $field->setBulkEditGroupKey('extension');
         }
       }
 
       $extension_fields = mpull($extension_fields, null, 'getKey');
 
       foreach ($extension_fields as $key => $field) {
         $fields[$key] = $field;
       }
     }
 
     $config = $this->getEditEngineConfiguration();
     $fields = $this->willConfigureFields($object, $fields);
     $fields = $config->applyConfigurationToFields($this, $object, $fields);
 
     $fields = $this->applyPageToFields($object, $fields);
 
     return $fields;
   }
 
   protected function willConfigureFields($object, array $fields) {
     return $fields;
   }
 
   final public function supportsSubtypes() {
     try {
       $object = $this->newEditableObject();
     } catch (Exception $ex) {
       return false;
     }
 
     return ($object instanceof PhabricatorEditEngineSubtypeInterface);
   }
 
   final public function newSubtypeMap() {
     return $this->newEditableObject()->newEditEngineSubtypeMap();
   }
 
 
 /* -(  Display Text  )------------------------------------------------------- */
 
 
   /**
    * @task text
    */
   abstract public function getEngineName();
 
 
   /**
    * @task text
    */
   abstract protected function getObjectCreateTitleText($object);
 
   /**
    * @task text
    */
   protected function getFormHeaderText($object) {
     $config = $this->getEditEngineConfiguration();
     return $config->getName();
   }
 
   /**
    * @task text
    */
   abstract protected function getObjectEditTitleText($object);
 
 
   /**
    * @task text
    */
   abstract protected function getObjectCreateShortText();
 
 
   /**
    * @task text
    */
   abstract protected function getObjectName();
 
 
   /**
    * @task text
    */
   abstract protected function getObjectEditShortText($object);
 
 
   /**
    * @task text
    */
   protected function getObjectCreateButtonText($object) {
     return $this->getObjectCreateTitleText($object);
   }
 
 
   /**
    * @task text
    */
   protected function getObjectEditButtonText($object) {
     return pht('Save Changes');
   }
 
 
   /**
    * @task text
    */
   protected function getCommentViewSeriousHeaderText($object) {
     return pht('Take Action');
   }
 
 
   /**
    * @task text
    */
   protected function getCommentViewSeriousButtonText($object) {
     return pht('Submit');
   }
 
 
   /**
    * @task text
    */
   protected function getCommentViewHeaderText($object) {
     return $this->getCommentViewSeriousHeaderText($object);
   }
 
 
   /**
    * @task text
    */
   protected function getCommentViewButtonText($object) {
     return $this->getCommentViewSeriousButtonText($object);
   }
 
 
   /**
    * @task text
    */
   protected function getPageHeader($object) {
     return null;
   }
 
   /**
    * Set default placeholder plain text in the comment textarea of the engine.
    * To be overwritten by conditions defined in the child EditEngine class.
    *
-   * @param  object Object in which the comment textarea is displayed.
+   * @param  object $object Object in which the comment textarea is displayed.
    * @return string Placeholder text to display in the comment textarea.
    * @task text
    */
   public function getCommentFieldPlaceholderText($object) {
     return '';
   }
 
   /**
    * Return a human-readable header describing what this engine is used to do,
    * like "Configure Maniphest Task Forms".
    *
    * @return string Human-readable description of the engine.
    * @task text
    */
   abstract public function getSummaryHeader();
 
 
   /**
    * Return a human-readable summary of what this engine is used to do.
    *
    * @return string Human-readable description of the engine.
    * @task text
    */
   abstract public function getSummaryText();
 
 
 
 
 /* -(  Edit Engine Configuration  )------------------------------------------ */
 
 
   protected function supportsEditEngineConfiguration() {
     return true;
   }
 
   final protected function getEditEngineConfiguration() {
     return $this->editEngineConfiguration;
   }
 
   public function newConfigurationQuery() {
     return id(new PhabricatorEditEngineConfigurationQuery())
       ->setViewer($this->getViewer())
       ->withEngineKeys(array($this->getEngineKey()));
   }
 
   private function loadEditEngineConfigurationWithQuery(
     PhabricatorEditEngineConfigurationQuery $query,
     $sort_method) {
 
     if ($sort_method) {
       $results = $query->execute();
       $results = msort($results, $sort_method);
       $result = head($results);
     } else {
       $result = $query->executeOne();
     }
 
     if (!$result) {
       return null;
     }
 
     $this->editEngineConfiguration = $result;
     return $result;
   }
 
   private function loadEditEngineConfigurationWithIdentifier($identifier) {
     $query = $this->newConfigurationQuery()
       ->withIdentifiers(array($identifier));
 
     return $this->loadEditEngineConfigurationWithQuery($query, null);
   }
 
   private function loadDefaultConfiguration() {
     $query = $this->newConfigurationQuery()
       ->withIdentifiers(
         array(
           self::EDITENGINECONFIG_DEFAULT,
         ))
       ->withIgnoreDatabaseConfigurations(true);
 
     return $this->loadEditEngineConfigurationWithQuery($query, null);
   }
 
   private function loadDefaultCreateConfiguration() {
     $query = $this->newConfigurationQuery()
       ->withIsDefault(true)
       ->withIsDisabled(false);
 
     return $this->loadEditEngineConfigurationWithQuery(
       $query,
       'getCreateSortKey');
   }
 
   public function loadDefaultEditConfiguration($object) {
     $query = $this->newConfigurationQuery()
       ->withIsEdit(true)
       ->withIsDisabled(false);
 
     // If this object supports subtyping, we edit it with a form of the same
     // subtype: so "bug" tasks get edited with "bug" forms.
     if ($object instanceof PhabricatorEditEngineSubtypeInterface) {
       $query->withSubtypes(
         array(
           $object->getEditEngineSubtype(),
         ));
     }
 
     return $this->loadEditEngineConfigurationWithQuery(
       $query,
       'getEditSortKey');
   }
 
   final public function getBuiltinEngineConfigurations() {
     $configurations = $this->newBuiltinEngineConfigurations();
 
     if (!$configurations) {
       throw new Exception(
         pht(
           'EditEngine ("%s") returned no builtin engine configurations, but '.
           'an edit engine must have at least one configuration.',
           get_class($this)));
     }
 
     assert_instances_of($configurations, 'PhabricatorEditEngineConfiguration');
 
     $has_default = false;
     foreach ($configurations as $config) {
       if ($config->getBuiltinKey() == self::EDITENGINECONFIG_DEFAULT) {
         $has_default = true;
       }
     }
 
     if (!$has_default) {
       $first = head($configurations);
       if (!$first->getBuiltinKey()) {
         $first
           ->setBuiltinKey(self::EDITENGINECONFIG_DEFAULT)
           ->setIsDefault(true)
           ->setIsEdit(true);
 
         $first_name = $first->getName();
 
         if ($first_name === null || $first_name === '') {
           $first->setName($this->getObjectCreateShortText());
         }
     } else {
         throw new Exception(
           pht(
             'EditEngine ("%s") returned builtin engine configurations, '.
             'but none are marked as default and the first configuration has '.
             'a different builtin key already. Mark a builtin as default or '.
             'omit the key from the first configuration',
             get_class($this)));
       }
     }
 
     $builtins = array();
     foreach ($configurations as $key => $config) {
       $builtin_key = $config->getBuiltinKey();
 
       if ($builtin_key === null) {
         throw new Exception(
           pht(
             'EditEngine ("%s") returned builtin engine configurations, '.
             'but one (with key "%s") is missing a builtin key. Provide a '.
             'builtin key for each configuration (you can omit it from the '.
             'first configuration in the list to automatically assign the '.
             'default key).',
             get_class($this),
             $key));
       }
 
       if (isset($builtins[$builtin_key])) {
         throw new Exception(
           pht(
             'EditEngine ("%s") returned builtin engine configurations, '.
             'but at least two specify the same builtin key ("%s"). Engines '.
             'must have unique builtin keys.',
             get_class($this),
             $builtin_key));
       }
 
       $builtins[$builtin_key] = $config;
     }
 
 
     return $builtins;
   }
 
   protected function newBuiltinEngineConfigurations() {
     return array(
       $this->newConfiguration(),
     );
   }
 
   final protected function newConfiguration() {
     return PhabricatorEditEngineConfiguration::initializeNewConfiguration(
       $this->getViewer(),
       $this);
   }
 
 
 /* -(  Managing URIs  )------------------------------------------------------ */
 
 
   /**
    * @task uri
    */
   abstract protected function getObjectViewURI($object);
 
 
   /**
    * @task uri
    */
   protected function getObjectCreateCancelURI($object) {
     return $this->getApplication()->getApplicationURI();
   }
 
 
   /**
    * @task uri
    */
   protected function getEditorURI() {
     return $this->getApplication()->getApplicationURI('edit/');
   }
 
 
   /**
    * @task uri
    */
   protected function getObjectEditCancelURI($object) {
     return $this->getObjectViewURI($object);
   }
 
   /**
    * @task uri
    */
   public function getCreateURI($form_key) {
     try {
       $create_uri = $this->getEditURI(null, "form/{$form_key}/");
     } catch (Exception $ex) {
       $create_uri = null;
     }
 
     return $create_uri;
   }
 
   /**
    * @task uri
    */
   public function getEditURI($object = null, $path = null) {
     $parts = array();
 
     $parts[] = $this->getEditorURI();
 
     if ($object && $object->getID()) {
       $parts[] = $object->getID().'/';
     }
 
     if ($path !== null) {
       $parts[] = $path;
     }
 
     return implode('', $parts);
   }
 
   public function getEffectiveObjectViewURI($object) {
     if ($this->getIsCreate()) {
       return $this->getObjectViewURI($object);
     }
 
     $page = $this->getSelectedPage();
     if ($page) {
       $view_uri = $page->getViewURI();
       if ($view_uri !== null) {
         return $view_uri;
       }
     }
 
     return $this->getObjectViewURI($object);
   }
 
   public function getEffectiveObjectEditDoneURI($object) {
     return $this->getEffectiveObjectViewURI($object);
   }
 
   public function getEffectiveObjectEditCancelURI($object) {
     $page = $this->getSelectedPage();
     if ($page) {
       $view_uri = $page->getViewURI();
       if ($view_uri !== null) {
         return $view_uri;
       }
     }
 
     return $this->getObjectEditCancelURI($object);
   }
 
 
 /* -(  Creating and Loading Objects  )--------------------------------------- */
 
 
   /**
    * Initialize a new object for creation.
    *
    * @return object Newly initialized object.
    * @task load
    */
   abstract protected function newEditableObject();
 
 
   /**
    * Build an empty query for objects.
    *
    * @return PhabricatorPolicyAwareQuery Query.
    * @task load
    */
   abstract protected function newObjectQuery();
 
 
   /**
    * Test if this workflow is creating a new object or editing an existing one.
    *
    * @return bool True if a new object is being created.
    * @task load
    */
   final public function getIsCreate() {
     return $this->isCreate;
   }
 
   /**
    * Initialize a new object for object creation via Conduit.
    *
    * @return object Newly initialized object.
-   * @param list<wild> Raw transactions.
+   * @param list<wild> $raw_xactions Raw transactions.
    * @task load
    */
   protected function newEditableObjectFromConduit(array $raw_xactions) {
     return $this->newEditableObject();
   }
 
   /**
    * Initialize a new object for documentation creation.
    *
    * @return object Newly initialized object.
    * @task load
    */
   protected function newEditableObjectForDocumentation() {
     return $this->newEditableObject();
   }
 
   /**
    * Flag this workflow as a create or edit.
    *
-   * @param bool True if this is a create workflow.
+   * @param bool $is_create True if this is a create workflow.
    * @return this
    * @task load
    */
   private function setIsCreate($is_create) {
     $this->isCreate = $is_create;
     return $this;
   }
 
 
   /**
    * Try to load an object by ID, PHID, or monogram. This is done primarily
    * to make Conduit a little easier to use.
    *
-   * @param wild ID, PHID, or monogram.
-   * @param list<const> List of required capability constants, or omit for
-   *   defaults.
+   * @param wild $identifier ID, PHID, or monogram.
+   * @param list<const>? $capabilities List of required capability constants,
+   *   or omit for defaults.
    * @return object Corresponding editable object.
    * @task load
    */
   private function newObjectFromIdentifier(
     $identifier,
     array $capabilities = array()) {
     if (is_int($identifier) || ctype_digit($identifier)) {
       $object = $this->newObjectFromID($identifier, $capabilities);
 
       if (!$object) {
         throw new Exception(
           pht(
             'No object exists with ID "%s".',
             $identifier));
       }
 
       return $object;
     }
 
     $type_unknown = PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN;
     if (phid_get_type($identifier) != $type_unknown) {
       $object = $this->newObjectFromPHID($identifier, $capabilities);
 
       if (!$object) {
         throw new Exception(
           pht(
             'No object exists with PHID "%s".',
             $identifier));
       }
 
       return $object;
     }
 
     $target = id(new PhabricatorObjectQuery())
       ->setViewer($this->getViewer())
       ->withNames(array($identifier))
       ->executeOne();
     if (!$target) {
       throw new Exception(
         pht(
           'Monogram "%s" does not identify a valid object.',
           $identifier));
     }
 
     $expect = $this->newEditableObject();
     $expect_class = get_class($expect);
     $target_class = get_class($target);
     if ($expect_class !== $target_class) {
       throw new Exception(
         pht(
           'Monogram "%s" identifies an object of the wrong type. Loaded '.
           'object has class "%s", but this editor operates on objects of '.
           'type "%s".',
           $identifier,
           $target_class,
           $expect_class));
     }
 
     // Load the object by PHID using this engine's standard query. This makes
     // sure it's really valid, goes through standard policy check logic, and
     // picks up any `need...()` clauses we want it to load with.
 
     $object = $this->newObjectFromPHID($target->getPHID(), $capabilities);
     if (!$object) {
       throw new Exception(
         pht(
           'Failed to reload object identified by monogram "%s" when '.
           'querying by PHID.',
           $identifier));
     }
 
     return $object;
   }
 
   /**
    * Load an object by ID.
    *
-   * @param int Object ID.
-   * @param list<const> List of required capability constants, or omit for
-   *   defaults.
+   * @param int $id Object ID.
+   * @param list<const>? $capabilities List of required capability constants,
+   *   or omit for defaults.
    * @return object|null Object, or null if no such object exists.
    * @task load
    */
   private function newObjectFromID($id, array $capabilities = array()) {
     $query = $this->newObjectQuery()
       ->withIDs(array($id));
 
     return $this->newObjectFromQuery($query, $capabilities);
   }
 
 
   /**
    * Load an object by PHID.
    *
-   * @param phid Object PHID.
-   * @param list<const> List of required capability constants, or omit for
-   *   defaults.
+   * @param phid $phid Object PHID.
+   * @param list<const>? $capabilities List of required capability constants,
+   *   or omit for defaults.
    * @return object|null Object, or null if no such object exists.
    * @task load
    */
   private function newObjectFromPHID($phid, array $capabilities = array()) {
     $query = $this->newObjectQuery()
       ->withPHIDs(array($phid));
 
     return $this->newObjectFromQuery($query, $capabilities);
   }
 
 
   /**
    * Load an object given a configured query.
    *
-   * @param PhabricatorPolicyAwareQuery Configured query.
-   * @param list<const> List of required capability constants, or omit for
-   *  defaults.
+   * @param PhabricatorPolicyAwareQuery $query Configured query.
+   * @param list<const>? $capabilities List of required capability constants,
+   *  or omit for defaults.
    * @return object|null Object, or null if no such object exists.
    * @task load
    */
   private function newObjectFromQuery(
     PhabricatorPolicyAwareQuery $query,
     array $capabilities = array()) {
 
     $viewer = $this->getViewer();
 
     if (!$capabilities) {
       $capabilities = array(
         PhabricatorPolicyCapability::CAN_VIEW,
         PhabricatorPolicyCapability::CAN_EDIT,
       );
     }
 
     $object = $query
       ->setViewer($viewer)
       ->requireCapabilities($capabilities)
       ->executeOne();
     if (!$object) {
       return null;
     }
 
     return $object;
   }
 
 
   /**
    * Verify that an object is appropriate for editing.
    *
-   * @param wild Loaded value.
+   * @param wild $object Loaded value.
    * @return void
    * @task load
    */
   private function validateObject($object) {
     if (!$object || !is_object($object)) {
       throw new Exception(
         pht(
           'EditEngine "%s" created or loaded an invalid object: object must '.
           'actually be an object, but is of some other type ("%s").',
           get_class($this),
           gettype($object)));
     }
 
     if (!($object instanceof PhabricatorApplicationTransactionInterface)) {
       throw new Exception(
         pht(
           'EditEngine "%s" created or loaded an invalid object: object (of '.
           'class "%s") must implement "%s", but does not.',
           get_class($this),
           get_class($object),
           'PhabricatorApplicationTransactionInterface'));
     }
   }
 
 
 /* -(  Responding to Web Requests  )----------------------------------------- */
 
 
   final public function buildResponse() {
     $viewer = $this->getViewer();
     $controller = $this->getController();
     $request = $controller->getRequest();
 
     $action = $this->getEditAction();
 
     $capabilities = array();
     $use_default = false;
     $require_create = true;
     switch ($action) {
       case 'comment':
         $capabilities = array(
           PhabricatorPolicyCapability::CAN_VIEW,
         );
         $use_default = true;
         break;
       case 'parameters':
         $use_default = true;
         break;
       case 'nodefault':
       case 'nocreate':
       case 'nomanage':
         $require_create = false;
         break;
       default:
         break;
     }
 
     $object = $this->getTargetObject();
     if (!$object) {
       $id = $request->getURIData('id');
 
       if ($id) {
         $this->setIsCreate(false);
         $object = $this->newObjectFromID($id, $capabilities);
         if (!$object) {
           return new Aphront404Response();
         }
       } else {
         // Make sure the viewer has permission to create new objects of
         // this type if we're going to create a new object.
         if ($require_create) {
           $this->requireCreateCapability();
         }
 
         $this->setIsCreate(true);
         $object = $this->newEditableObject();
       }
     } else {
       $id = $object->getID();
     }
 
     $this->validateObject($object);
 
     if ($use_default) {
       $config = $this->loadDefaultConfiguration();
       if (!$config) {
         return new Aphront404Response();
       }
     } else {
       $form_key = $request->getURIData('formKey');
       if (phutil_nonempty_string($form_key)) {
         $config = $this->loadEditEngineConfigurationWithIdentifier($form_key);
 
         if (!$config) {
           return new Aphront404Response();
         }
 
         if ($id && !$config->getIsEdit()) {
           return $this->buildNotEditFormRespose($object, $config);
         }
       } else {
         if ($id) {
           $config = $this->loadDefaultEditConfiguration($object);
           if (!$config) {
             return $this->buildNoEditResponse($object);
           }
         } else {
           $config = $this->loadDefaultCreateConfiguration();
           if (!$config) {
             return $this->buildNoCreateResponse($object);
           }
         }
       }
     }
 
     if ($config->getIsDisabled()) {
       return $this->buildDisabledFormResponse($object, $config);
     }
 
     $page_key = $request->getURIData('pageKey');
     if (!phutil_nonempty_string($page_key)) {
       $pages = $this->getPages($object);
       if ($pages) {
         $page_key = head_key($pages);
       }
     }
 
     if (phutil_nonempty_string($page_key)) {
       $page = $this->selectPage($object, $page_key);
       if (!$page) {
         return new Aphront404Response();
       }
     }
 
     switch ($action) {
       case 'parameters':
         return $this->buildParametersResponse($object);
       case 'nodefault':
         return $this->buildNoDefaultResponse($object);
       case 'nocreate':
         return $this->buildNoCreateResponse($object);
       case 'nomanage':
         return $this->buildNoManageResponse($object);
       case 'comment':
         return $this->buildCommentResponse($object);
       default:
         return $this->buildEditResponse($object);
     }
   }
 
   private function buildCrumbs($object, $final = false) {
     $controller = $this->getController();
 
     $crumbs = $controller->buildApplicationCrumbsForEditEngine();
     if ($this->getIsCreate()) {
       $create_text = $this->getObjectCreateShortText();
       if ($final) {
         $crumbs->addTextCrumb($create_text);
       } else {
         $edit_uri = $this->getEditURI($object);
         $crumbs->addTextCrumb($create_text, $edit_uri);
       }
     } else {
       $crumbs->addTextCrumb(
         $this->getObjectEditShortText($object),
         $this->getEffectiveObjectViewURI($object));
 
       $edit_text = pht('Edit');
       if ($final) {
         $crumbs->addTextCrumb($edit_text);
       } else {
         $edit_uri = $this->getEditURI($object);
         $crumbs->addTextCrumb($edit_text, $edit_uri);
       }
     }
 
     return $crumbs;
   }
 
   private function buildEditResponse($object) {
     $viewer = $this->getViewer();
     $controller = $this->getController();
     $request = $controller->getRequest();
 
     $fields = $this->buildEditFields($object);
     $template = $object->getApplicationTransactionTemplate();
 
     $page_state = new PhabricatorEditEnginePageState();
 
     if ($this->getIsCreate()) {
       $cancel_uri = $this->getObjectCreateCancelURI($object);
       $submit_button = $this->getObjectCreateButtonText($object);
 
       $page_state->setIsCreate(true);
     } else {
       $cancel_uri = $this->getEffectiveObjectEditCancelURI($object);
       $submit_button = $this->getObjectEditButtonText($object);
     }
 
     $config = $this->getEditEngineConfiguration()
       ->attachEngine($this);
 
     // NOTE: Don't prompt users to override locks when creating objects,
     // even if the default settings would create a locked object.
 
     $can_interact = PhabricatorPolicyFilter::canInteract($viewer, $object);
     if (!$can_interact &&
         !$this->getIsCreate() &&
         !$request->getBool('editEngine') &&
         !$request->getBool('overrideLock')) {
 
       $lock = PhabricatorEditEngineLock::newForObject($viewer, $object);
 
       $dialog = $this->getController()
         ->newDialog()
         ->addHiddenInput('overrideLock', true)
         ->setDisableWorkflowOnSubmit(true)
         ->addCancelButton($cancel_uri);
 
       return $lock->willPromptUserForLockOverrideWithDialog($dialog);
     }
 
     $validation_exception = null;
     if ($request->isFormOrHisecPost() && $request->getBool('editEngine')) {
       $page_state->setIsSubmit(true);
 
       $submit_fields = $fields;
 
       foreach ($submit_fields as $key => $field) {
         if (!$field->shouldGenerateTransactionsFromSubmit()) {
           unset($submit_fields[$key]);
           continue;
         }
       }
 
       // Before we read the submitted values, store a copy of what we would
       // use if the form was empty so we can figure out which transactions are
       // just setting things to their default values for the current form.
       $defaults = array();
       foreach ($submit_fields as $key => $field) {
         $defaults[$key] = $field->getValueForTransaction();
       }
 
       foreach ($submit_fields as $key => $field) {
         $field->setIsSubmittedForm(true);
 
         if (!$field->shouldReadValueFromSubmit()) {
           continue;
         }
 
         $field->readValueFromSubmit($request);
       }
 
       $xactions = array();
 
       if ($this->getIsCreate()) {
         $xactions[] = id(clone $template)
           ->setTransactionType(PhabricatorTransactions::TYPE_CREATE);
 
         if ($this->supportsSubtypes()) {
           $xactions[] = id(clone $template)
             ->setTransactionType(PhabricatorTransactions::TYPE_SUBTYPE)
             ->setNewValue($config->getSubtype());
         }
       }
 
       foreach ($submit_fields as $key => $field) {
         $field_value = $field->getValueForTransaction();
 
         $type_xactions = $field->generateTransactions(
           clone $template,
           array(
             'value' => $field_value,
           ));
 
         foreach ($type_xactions as $type_xaction) {
           $default = $defaults[$key];
 
           if ($default === $field->getValueForTransaction()) {
             $type_xaction->setIsDefaultTransaction(true);
           }
 
           $xactions[] = $type_xaction;
         }
       }
 
       $editor = $object->getApplicationTransactionEditor()
         ->setActor($viewer)
         ->setContentSourceFromRequest($request)
         ->setCancelURI($cancel_uri)
         ->setContinueOnNoEffect(true);
 
       try {
         $xactions = $this->willApplyTransactions($object, $xactions);
 
         $editor->applyTransactions($object, $xactions);
 
         $this->didApplyTransactions($object, $xactions);
 
         return $this->newEditResponse($request, $object, $xactions);
       } catch (PhabricatorApplicationTransactionValidationException $ex) {
         $validation_exception = $ex;
 
         foreach ($fields as $field) {
           $message = $this->getValidationExceptionShortMessage($ex, $field);
           if ($message === null) {
             continue;
           }
 
           $field->setControlError($message);
         }
 
         $page_state->setIsError(true);
       }
     } else {
       if ($this->getIsCreate()) {
         $template = $request->getStr('template');
 
         if (phutil_nonempty_string($template)) {
           $template_object = $this->newObjectFromIdentifier(
             $template,
             array(
               PhabricatorPolicyCapability::CAN_VIEW,
             ));
           if (!$template_object) {
             return new Aphront404Response();
           }
         } else {
           $template_object = null;
         }
 
         if ($template_object) {
           $copy_fields = $this->buildEditFields($template_object);
           $copy_fields = mpull($copy_fields, null, 'getKey');
           foreach ($copy_fields as $copy_key => $copy_field) {
             if (!$copy_field->getIsCopyable()) {
               unset($copy_fields[$copy_key]);
             }
           }
         } else {
           $copy_fields = array();
         }
 
         foreach ($fields as $field) {
           if (!$field->shouldReadValueFromRequest()) {
             continue;
           }
 
           $field_key = $field->getKey();
           if (isset($copy_fields[$field_key])) {
             $field->readValueFromField($copy_fields[$field_key]);
           }
 
           $field->readValueFromRequest($request);
         }
       }
     }
 
     $action_button = $this->buildEditFormActionButton($object);
 
     if ($this->getIsCreate()) {
       $header_text = $this->getFormHeaderText($object);
     } else {
       $header_text = $this->getObjectEditTitleText($object);
     }
 
     $show_preview = !$request->isAjax();
 
     if ($show_preview) {
       $previews = array();
       foreach ($fields as $field) {
         $preview = $field->getPreviewPanel();
         if (!$preview) {
           continue;
         }
 
         $control_id = $field->getControlID();
 
         $preview
           ->setControlID($control_id)
           ->setPreviewURI('/transactions/remarkuppreview/');
 
         $previews[] = $preview;
       }
     } else {
       $previews = array();
     }
 
     $form = $this->buildEditForm($object, $fields);
 
     $crumbs = $this->buildCrumbs($object, $final = true);
     $crumbs->setBorder(true);
 
     if ($request->isAjax()) {
       return $this->getController()
         ->newDialog()
         ->setWidth(AphrontDialogView::WIDTH_FULL)
         ->setTitle($header_text)
         ->setValidationException($validation_exception)
         ->appendForm($form)
         ->addCancelButton($cancel_uri)
         ->addSubmitButton($submit_button);
     }
 
     $box_header = id(new PHUIHeaderView())
       ->setHeader($header_text);
 
     if ($action_button) {
       $box_header->addActionLink($action_button);
     }
 
     $request_submit_key = $request->getSubmitKey();
     $engine_submit_key = $this->getEditEngineSubmitKey();
 
     if ($request_submit_key === $engine_submit_key) {
       $page_state->setIsSubmit(true);
       $page_state->setIsSave(true);
     }
 
     $head = $this->newEditFormHeadContent($page_state);
     $tail = $this->newEditFormTailContent($page_state);
 
     $box = id(new PHUIObjectBoxView())
       ->setUser($viewer)
       ->setHeader($box_header)
       ->setValidationException($validation_exception)
       ->setBackground(PHUIObjectBoxView::WHITE_CONFIG)
       ->appendChild($form);
 
     $content = array(
       $head,
       $box,
       $previews,
       $tail,
     );
 
     $view = new PHUITwoColumnView();
 
     $page_header = $this->getPageHeader($object);
     if ($page_header) {
       $view->setHeader($page_header);
     }
 
     $view->setFooter($content);
 
     $page = $controller->newPage()
       ->setTitle($header_text)
       ->setCrumbs($crumbs)
       ->appendChild($view);
 
     $navigation = $this->getNavigation();
     if ($navigation) {
       $page->setNavigation($navigation);
     }
 
     return $page;
   }
 
   protected function newEditFormHeadContent(
     PhabricatorEditEnginePageState $state) {
     return null;
   }
 
   protected function newEditFormTailContent(
     PhabricatorEditEnginePageState $state) {
     return null;
   }
 
   protected function newEditResponse(
     AphrontRequest $request,
     $object,
     array $xactions) {
 
     $submit_cookie = PhabricatorCookies::COOKIE_SUBMIT;
     $submit_key = $this->getEditEngineSubmitKey();
 
     $request->setTemporaryCookie($submit_cookie, $submit_key);
 
     return id(new AphrontRedirectResponse())
       ->setURI($this->getEffectiveObjectEditDoneURI($object));
   }
 
   private function getEditEngineSubmitKey() {
     return 'edit-engine/'.$this->getEngineKey();
   }
 
   private function buildEditForm($object, array $fields) {
     $viewer = $this->getViewer();
     $controller = $this->getController();
     $request = $controller->getRequest();
 
     $fields = $this->willBuildEditForm($object, $fields);
 
     $request_path = $request->getPath();
 
     $form = id(new AphrontFormView())
       ->setUser($viewer)
       ->setAction($request_path)
       ->addHiddenInput('editEngine', 'true');
 
     foreach ($this->contextParameters as $param) {
       $form->addHiddenInput($param, $request->getStr($param));
     }
 
     $requires_mfa = false;
     if ($object instanceof PhabricatorEditEngineMFAInterface) {
       $mfa_engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
         ->setViewer($viewer);
       $requires_mfa = $mfa_engine->shouldRequireMFA();
     }
 
     if ($requires_mfa) {
       $message = pht(
         'You will be required to provide multi-factor credentials to make '.
         'changes.');
       $form->appendChild(
         id(new PHUIInfoView())
           ->setSeverity(PHUIInfoView::SEVERITY_MFA)
           ->setErrors(array($message)));
 
       // TODO: This should also set workflow on the form, so the user doesn't
       // lose any form data if they "Cancel". However, Maniphest currently
       // overrides "newEditResponse()" if the request is Ajax and returns a
       // bag of view data. This can reasonably be cleaned up when workboards
       // get their next iteration.
     }
 
     foreach ($fields as $field) {
       if (!$field->getIsFormField()) {
         continue;
       }
 
       $field->appendToForm($form);
     }
 
     if ($this->getIsCreate()) {
       $cancel_uri = $this->getObjectCreateCancelURI($object);
       $submit_button = $this->getObjectCreateButtonText($object);
     } else {
       $cancel_uri = $this->getEffectiveObjectEditCancelURI($object);
       $submit_button = $this->getObjectEditButtonText($object);
     }
 
     if (!$request->isAjax()) {
       $buttons = id(new AphrontFormSubmitControl())
         ->setValue($submit_button);
 
       if ($cancel_uri) {
         $buttons->addCancelButton($cancel_uri);
       }
 
       $form->appendControl($buttons);
     }
 
     return $form;
   }
 
   protected function willBuildEditForm($object, array $fields) {
     return $fields;
   }
 
   private function buildEditFormActionButton($object) {
     if (!$this->isEngineConfigurable()) {
       return null;
     }
 
     $viewer = $this->getViewer();
 
     $action_view = id(new PhabricatorActionListView())
       ->setUser($viewer);
 
     foreach ($this->buildEditFormActions($object) as $action) {
       $action_view->addAction($action);
     }
 
     $action_button = id(new PHUIButtonView())
       ->setTag('a')
       ->setText(pht('Configure Form'))
       ->setHref('#')
       ->setIcon('fa-gear')
       ->setDropdownMenu($action_view);
 
     return $action_button;
   }
 
   private function buildEditFormActions($object) {
     $actions = array();
 
     if ($this->supportsEditEngineConfiguration()) {
       $engine_key = $this->getEngineKey();
       $config = $this->getEditEngineConfiguration();
 
       $can_manage = PhabricatorPolicyFilter::hasCapability(
         $this->getViewer(),
         $config,
         PhabricatorPolicyCapability::CAN_EDIT);
 
       if ($can_manage) {
         $manage_uri = $config->getURI();
       } else {
         $manage_uri = $this->getEditURI(null, 'nomanage/');
       }
 
       $view_uri = "/transactions/editengine/{$engine_key}/";
 
       $actions[] = id(new PhabricatorActionView())
         ->setLabel(true)
         ->setName(pht('Configuration'));
 
       $actions[] = id(new PhabricatorActionView())
         ->setName(pht('View Form Configurations'))
         ->setIcon('fa-list-ul')
         ->setHref($view_uri);
 
       $actions[] = id(new PhabricatorActionView())
         ->setName(pht('Edit Form Configuration'))
         ->setIcon('fa-pencil')
         ->setHref($manage_uri)
         ->setDisabled(!$can_manage)
         ->setWorkflow(!$can_manage);
     }
 
     $actions[] = id(new PhabricatorActionView())
       ->setLabel(true)
       ->setName(pht('Documentation'));
 
     $actions[] = id(new PhabricatorActionView())
       ->setName(pht('Using HTTP Parameters'))
       ->setIcon('fa-book')
       ->setHref($this->getEditURI($object, 'parameters/'));
 
     $doc_href = PhabricatorEnv::getDoclink('User Guide: Customizing Forms');
     $actions[] = id(new PhabricatorActionView())
       ->setName(pht('User Guide: Customizing Forms'))
       ->setIcon('fa-book')
       ->setHref($doc_href);
 
     return $actions;
   }
 
 
   public function newNUXButton($text) {
     $specs = $this->newCreateActionSpecifications(array());
     $head = head($specs);
 
     return id(new PHUIButtonView())
       ->setTag('a')
       ->setText($text)
       ->setHref($head['uri'])
       ->setDisabled($head['disabled'])
       ->setWorkflow($head['workflow'])
       ->setColor(PHUIButtonView::GREEN);
   }
 
 
   final public function addActionToCrumbs(
     PHUICrumbsView $crumbs,
     array $parameters = array()) {
     $viewer = $this->getViewer();
 
     $specs = $this->newCreateActionSpecifications($parameters);
 
     $head = head($specs);
     $menu_uri = $head['uri'];
 
     $dropdown = null;
     if (count($specs) > 1) {
       $menu_icon = 'fa-caret-square-o-down';
       $menu_name = $this->getObjectCreateShortText();
       $workflow = false;
       $disabled = false;
 
       $dropdown = id(new PhabricatorActionListView())
         ->setUser($viewer);
 
       foreach ($specs as $spec) {
         $dropdown->addAction(
           id(new PhabricatorActionView())
             ->setName($spec['name'])
             ->setIcon($spec['icon'])
             ->setHref($spec['uri'])
             ->setDisabled($head['disabled'])
             ->setWorkflow($head['workflow']));
       }
 
     } else {
       $menu_icon = $head['icon'];
       $menu_name = $head['name'];
 
       $workflow = $head['workflow'];
       $disabled = $head['disabled'];
     }
 
     $action = id(new PHUIListItemView())
       ->setName($menu_name)
       ->setHref($menu_uri)
       ->setIcon($menu_icon)
       ->setWorkflow($workflow)
       ->setDisabled($disabled);
 
     if ($dropdown) {
       $action->setDropdownMenu($dropdown);
     }
 
     $crumbs->addAction($action);
   }
 
 
   /**
    * Build a raw description of available "Create New Object" UI options so
    * other methods can build menus or buttons.
    */
   public function newCreateActionSpecifications(array $parameters) {
     $viewer = $this->getViewer();
 
     $can_create = $this->hasCreateCapability();
     if ($can_create) {
       $configs = $this->loadUsableConfigurationsForCreate();
     } else {
       $configs = array();
     }
 
     $disabled = false;
     $workflow = false;
 
     $menu_icon = 'fa-plus-square';
     $specs = array();
     if (!$configs) {
       if ($viewer->isLoggedIn()) {
         $disabled = true;
       } else {
         // If the viewer isn't logged in, assume they'll get hit with a login
         // dialog and are likely able to create objects after they log in.
         $disabled = false;
       }
       $workflow = true;
 
       if ($can_create) {
         $create_uri = $this->getEditURI(null, 'nodefault/');
       } else {
         $create_uri = $this->getEditURI(null, 'nocreate/');
       }
 
       $specs[] = array(
         'name' => $this->getObjectCreateShortText(),
         'uri' => $create_uri,
         'icon' => $menu_icon,
         'disabled' => $disabled,
         'workflow' => $workflow,
       );
     } else {
       foreach ($configs as $config) {
         $config_uri = $config->getCreateURI();
 
         if ($parameters) {
           $config_uri = (string)new PhutilURI($config_uri, $parameters);
         }
 
         $specs[] = array(
           'name' => $config->getDisplayName(),
           'uri' => $config_uri,
           'icon' => 'fa-plus',
           'disabled' => false,
           'workflow' => false,
         );
       }
     }
 
     return $specs;
   }
 
   final public function buildEditEngineCommentView($object) {
     $config = $this->loadDefaultEditConfiguration($object);
 
     if (!$config) {
       // TODO: This just nukes the entire comment form if you don't have access
       // to any edit forms. We might want to tailor this UX a bit.
       return id(new PhabricatorApplicationTransactionCommentView())
         ->setNoPermission(true);
     }
 
     $viewer = $this->getViewer();
 
     $can_interact = PhabricatorPolicyFilter::canInteract($viewer, $object);
     if (!$can_interact) {
       $lock = PhabricatorEditEngineLock::newForObject($viewer, $object);
 
       return id(new PhabricatorApplicationTransactionCommentView())
         ->setEditEngineLock($lock);
     }
 
     $object_phid = $object->getPHID();
     $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
 
     if ($is_serious) {
       $header_text = $this->getCommentViewSeriousHeaderText($object);
       $button_text = $this->getCommentViewSeriousButtonText($object);
     } else {
       $header_text = $this->getCommentViewHeaderText($object);
       $button_text = $this->getCommentViewButtonText($object);
     }
 
     $comment_uri = $this->getEditURI($object, 'comment/');
 
     $requires_mfa = false;
     if ($object instanceof PhabricatorEditEngineMFAInterface) {
       $mfa_engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
         ->setViewer($viewer);
       $requires_mfa = $mfa_engine->shouldRequireMFA();
     }
 
     $view = id(new PhabricatorApplicationTransactionCommentView())
       ->setUser($viewer)
       ->setHeaderText($header_text)
       ->setAction($comment_uri)
       ->setRequestURI(new PhutilURI($this->getObjectViewURI($object)))
       ->setRequiresMFA($requires_mfa)
       ->setObject($object)
       ->setEditEngine($this)
       ->setSubmitButtonName($button_text);
 
     $draft = PhabricatorVersionedDraft::loadDraft(
       $object_phid,
       $viewer->getPHID());
     if ($draft) {
       $view->setVersionedDraft($draft);
     }
 
     $view->setCurrentVersion($this->loadDraftVersion($object));
 
     $fields = $this->buildEditFields($object);
 
     $can_edit = PhabricatorPolicyFilter::hasCapability(
       $viewer,
       $object,
       PhabricatorPolicyCapability::CAN_EDIT);
 
     $comment_actions = array();
     foreach ($fields as $field) {
       if (!$field->shouldGenerateTransactionsFromComment()) {
         continue;
       }
 
       if (!$can_edit) {
         if (!$field->getCanApplyWithoutEditCapability()) {
           continue;
         }
       }
 
       $comment_action = $field->getCommentAction();
       if (!$comment_action) {
         continue;
       }
 
       $key = $comment_action->getKey();
 
       // TODO: Validate these better.
 
       $comment_actions[$key] = $comment_action;
     }
 
     $comment_actions = msortv($comment_actions, 'getSortVector');
 
     $view->setCommentActions($comment_actions);
 
     $comment_groups = $this->newCommentActionGroups();
     $view->setCommentActionGroups($comment_groups);
 
     return $view;
   }
 
   protected function loadDraftVersion($object) {
     $viewer = $this->getViewer();
 
     if (!$viewer->isLoggedIn()) {
       return null;
     }
 
     $template = $object->getApplicationTransactionTemplate();
     $conn_r = $template->establishConnection('r');
 
     // Find the most recent transaction the user has written. We'll use this
     // as a version number to make sure that out-of-date drafts get discarded.
     $result = queryfx_one(
       $conn_r,
       'SELECT id AS version FROM %T
         WHERE objectPHID = %s AND authorPHID = %s
         ORDER BY id DESC LIMIT 1',
       $template->getTableName(),
       $object->getPHID(),
       $viewer->getPHID());
 
     if ($result) {
       return (int)$result['version'];
     } else {
       return null;
     }
   }
 
 
 /* -(  Responding to HTTP Parameter Requests  )------------------------------ */
 
 
   /**
    * Respond to a request for documentation on HTTP parameters.
    *
-   * @param object Editable object.
+   * @param object $object Editable object.
    * @return AphrontResponse Response object.
    * @task http
    */
   private function buildParametersResponse($object) {
     $controller = $this->getController();
     $viewer = $this->getViewer();
     $request = $controller->getRequest();
     $fields = $this->buildEditFields($object);
 
     $crumbs = $this->buildCrumbs($object);
     $crumbs->addTextCrumb(pht('HTTP Parameters'));
     $crumbs->setBorder(true);
 
     $header_text = pht(
       'HTTP Parameters: %s',
       $this->getObjectCreateShortText());
 
     $header = id(new PHUIHeaderView())
       ->setHeader($header_text);
 
     $help_view = id(new PhabricatorApplicationEditHTTPParameterHelpView())
       ->setUser($viewer)
       ->setFields($fields);
 
     $document = id(new PHUIDocumentView())
       ->setUser($viewer)
       ->setHeader($header)
       ->appendChild($help_view);
 
     return $controller->newPage()
       ->setTitle(pht('HTTP Parameters'))
       ->setCrumbs($crumbs)
       ->appendChild($document);
   }
 
 
   private function buildError($object, $title, $body) {
     $cancel_uri = $this->getObjectCreateCancelURI($object);
 
     $dialog = $this->getController()
       ->newDialog()
       ->addCancelButton($cancel_uri);
 
     if ($title !== null) {
       $dialog->setTitle($title);
     }
 
     if ($body !== null) {
       $dialog->appendParagraph($body);
     }
 
     return $dialog;
   }
 
 
   private function buildNoDefaultResponse($object) {
     return $this->buildError(
       $object,
       pht('No Default Create Forms'),
       pht(
         'This application is not configured with any forms for creating '.
         'objects that are visible to you and enabled.'));
   }
 
   private function buildNoCreateResponse($object) {
     return $this->buildError(
       $object,
       pht('No Create Permission'),
       pht('You do not have permission to create these objects.'));
   }
 
   private function buildNoManageResponse($object) {
     return $this->buildError(
       $object,
       pht('No Manage Permission'),
       pht(
         'You do not have permission to configure forms for this '.
         'application.'));
   }
 
   private function buildNoEditResponse($object) {
     return $this->buildError(
       $object,
       pht('No Edit Forms'),
       pht(
         'You do not have access to any forms which are enabled and marked '.
         'as edit forms.'));
   }
 
   private function buildNotEditFormRespose($object, $config) {
     return $this->buildError(
       $object,
       pht('Not an Edit Form'),
       pht(
         'This form ("%s") is not marked as an edit form, so '.
         'it can not be used to edit objects.',
         $config->getName()));
   }
 
   private function buildDisabledFormResponse($object, $config) {
     return $this->buildError(
       $object,
       pht('Form Disabled'),
       pht(
         'This form ("%s") has been disabled, so it can not be used.',
         $config->getName()));
   }
 
   private function buildLockedObjectResponse($object) {
     $dialog = $this->buildError($object, null, null);
     $viewer = $this->getViewer();
 
     $lock = PhabricatorEditEngineLock::newForObject($viewer, $object);
     return $lock->willBlockUserInteractionWithDialog($dialog);
   }
 
   private function buildCommentResponse($object) {
     $viewer = $this->getViewer();
 
     if ($this->getIsCreate()) {
       return new Aphront404Response();
     }
 
     $controller = $this->getController();
     $request = $controller->getRequest();
 
     // NOTE: We handle hisec inside the transaction editor with "Sign With MFA"
     // comment actions.
     if (!$request->isFormOrHisecPost()) {
       return new Aphront400Response();
     }
 
     $can_interact = PhabricatorPolicyFilter::canInteract($viewer, $object);
     if (!$can_interact) {
       return $this->buildLockedObjectResponse($object);
     }
 
     $config = $this->loadDefaultEditConfiguration($object);
     if (!$config) {
       return new Aphront404Response();
     }
 
     $fields = $this->buildEditFields($object);
 
     $is_preview = $request->isPreviewRequest();
     $view_uri = $this->getEffectiveObjectViewURI($object);
 
     $template = $object->getApplicationTransactionTemplate();
     $comment_template = $template->getApplicationTransactionCommentObject();
 
     $comment_text = $request->getStr('comment');
 
     $comment_metadata = $request->getStr('comment_metadata');
     if (phutil_nonempty_string($comment_metadata)) {
       $comment_metadata = phutil_json_decode($comment_metadata);
     }
 
     $actions = $request->getStr('editengine.actions');
     if ($actions) {
       $actions = phutil_json_decode($actions);
     }
 
     if ($is_preview) {
       $version_key = PhabricatorVersionedDraft::KEY_VERSION;
       $request_version = $request->getInt($version_key);
       $current_version = $this->loadDraftVersion($object);
       if ($request_version >= $current_version) {
         $draft = PhabricatorVersionedDraft::loadOrCreateDraft(
           $object->getPHID(),
           $viewer->getPHID(),
           $current_version);
 
         $draft
           ->setProperty('comment', $comment_text)
           ->setProperty('metadata', $comment_metadata)
           ->setProperty('actions', $actions)
           ->save();
 
         $draft_engine = $this->newDraftEngine($object);
         if ($draft_engine) {
           $draft_engine
             ->setVersionedDraft($draft)
             ->synchronize();
         }
       }
     }
 
     $xactions = array();
 
     $can_edit = PhabricatorPolicyFilter::hasCapability(
       $viewer,
       $object,
       PhabricatorPolicyCapability::CAN_EDIT);
 
     if ($actions) {
       $action_map = array();
       foreach ($actions as $action) {
         $type = idx($action, 'type');
         if (!$type) {
           continue;
         }
 
         if (empty($fields[$type])) {
           continue;
         }
 
         $action_map[$type] = $action;
       }
 
       foreach ($action_map as $type => $action) {
         $field = $fields[$type];
 
         if (!$field->shouldGenerateTransactionsFromComment()) {
           continue;
         }
 
         // If you don't have edit permission on the object, you're limited in
         // which actions you can take via the comment form. Most actions
         // need edit permission, but some actions (like "Accept Revision")
         // can be applied by anyone with view permission.
         if (!$can_edit) {
           if (!$field->getCanApplyWithoutEditCapability()) {
             // We know the user doesn't have the capability, so this will
             // raise a policy exception.
             PhabricatorPolicyFilter::requireCapability(
               $viewer,
               $object,
               PhabricatorPolicyCapability::CAN_EDIT);
           }
         }
 
         if (array_key_exists('initialValue', $action)) {
           $field->setInitialValue($action['initialValue']);
         }
 
         $field->readValueFromComment(idx($action, 'value'));
 
         $type_xactions = $field->generateTransactions(
           clone $template,
           array(
             'value' => $field->getValueForTransaction(),
           ));
         foreach ($type_xactions as $type_xaction) {
           $xactions[] = $type_xaction;
         }
       }
     }
 
     $auto_xactions = $this->newAutomaticCommentTransactions($object);
     foreach ($auto_xactions as $xaction) {
       $xactions[] = $xaction;
     }
 
     if (phutil_nonempty_string($comment_text) || !$xactions) {
       $xactions[] = id(clone $template)
         ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT)
         ->setMetadataValue('remarkup.control', $comment_metadata)
         ->attachComment(
           id(clone $comment_template)
             ->setContent($comment_text));
     }
 
     $editor = $object->getApplicationTransactionEditor()
       ->setActor($viewer)
       ->setContinueOnNoEffect($request->isContinueRequest())
       ->setContinueOnMissingFields(true)
       ->setContentSourceFromRequest($request)
       ->setCancelURI($view_uri)
       ->setRaiseWarnings(!$request->getBool('editEngine.warnings'))
       ->setIsPreview($is_preview);
 
     try {
       $xactions = $editor->applyTransactions($object, $xactions);
     } catch (PhabricatorApplicationTransactionValidationException $ex) {
       return id(new PhabricatorApplicationTransactionValidationResponse())
         ->setCancelURI($view_uri)
         ->setException($ex);
     } catch (PhabricatorApplicationTransactionNoEffectException $ex) {
       return id(new PhabricatorApplicationTransactionNoEffectResponse())
         ->setCancelURI($view_uri)
         ->setException($ex);
     } catch (PhabricatorApplicationTransactionWarningException $ex) {
       return id(new PhabricatorApplicationTransactionWarningResponse())
         ->setObject($object)
         ->setCancelURI($view_uri)
         ->setException($ex);
     }
 
     if (!$is_preview) {
       PhabricatorVersionedDraft::purgeDrafts(
         $object->getPHID(),
         $viewer->getPHID());
 
       $draft_engine = $this->newDraftEngine($object);
       if ($draft_engine) {
         $draft_engine
           ->setVersionedDraft(null)
           ->synchronize();
       }
     }
 
     if ($request->isAjax() && $is_preview) {
       $preview_content = $this->newCommentPreviewContent($object, $xactions);
 
       $raw_view_data = $request->getStr('viewData');
       try {
         $view_data = phutil_json_decode($raw_view_data);
       } catch (Exception $ex) {
         $view_data = array();
       }
 
       return id(new PhabricatorApplicationTransactionResponse())
         ->setObject($object)
         ->setViewer($viewer)
         ->setTransactions($xactions)
         ->setIsPreview($is_preview)
         ->setViewData($view_data)
         ->setPreviewContent($preview_content);
     } else {
       return id(new AphrontRedirectResponse())
         ->setURI($view_uri);
     }
   }
 
   protected function newDraftEngine($object) {
     $viewer = $this->getViewer();
 
     if ($object instanceof PhabricatorDraftInterface) {
       $engine = $object->newDraftEngine();
     } else {
       $engine = new PhabricatorBuiltinDraftEngine();
     }
 
     return $engine
       ->setObject($object)
       ->setViewer($viewer);
   }
 
 
 /* -(  Conduit  )------------------------------------------------------------ */
 
 
   /**
    * Respond to a Conduit edit request.
    *
    * This method accepts a list of transactions to apply to an object, and
    * either edits an existing object or creates a new one.
    *
    * @task conduit
    */
   final public function buildConduitResponse(ConduitAPIRequest $request) {
     $viewer = $this->getViewer();
 
     $config = $this->loadDefaultConfiguration();
     if (!$config) {
       throw new Exception(
         pht(
           'Unable to load configuration for this EditEngine ("%s").',
           get_class($this)));
     }
 
     $raw_xactions = $this->getRawConduitTransactions($request);
 
     $identifier = $request->getValue('objectIdentifier');
     if ($identifier) {
       $this->setIsCreate(false);
 
       // After T13186, each transaction can individually weaken or replace the
       // capabilities required to apply it, so we no longer need CAN_EDIT to
       // attempt to apply transactions to objects. In practice, almost all
       // transactions require CAN_EDIT so we won't get very far if we don't
       // have it.
       $capabilities = array(
         PhabricatorPolicyCapability::CAN_VIEW,
       );
 
       $object = $this->newObjectFromIdentifier(
         $identifier,
         $capabilities);
     } else {
       $this->requireCreateCapability();
 
       $this->setIsCreate(true);
       $object = $this->newEditableObjectFromConduit($raw_xactions);
     }
 
     $this->validateObject($object);
 
     $fields = $this->buildEditFields($object);
 
     $types = $this->getConduitEditTypesFromFields($fields);
     $template = $object->getApplicationTransactionTemplate();
 
     $xactions = $this->getConduitTransactions(
       $request,
       $raw_xactions,
       $types,
       $template);
 
     $editor = $object->getApplicationTransactionEditor()
       ->setActor($viewer)
       ->setContentSource($request->newContentSource())
       ->setContinueOnNoEffect(true);
 
     if (!$this->getIsCreate()) {
       $editor->setContinueOnMissingFields(true);
     }
 
     $xactions = $editor->applyTransactions($object, $xactions);
 
     $xactions_struct = array();
     foreach ($xactions as $xaction) {
       $xactions_struct[] = array(
         'phid' => $xaction->getPHID(),
       );
     }
 
     return array(
       'object' => array(
         'id' => (int)$object->getID(),
         'phid' => $object->getPHID(),
       ),
       'transactions' => $xactions_struct,
     );
   }
 
   private function getRawConduitTransactions(ConduitAPIRequest $request) {
     $transactions_key = 'transactions';
 
     $xactions = $request->getValue($transactions_key);
     if (!is_array($xactions)) {
       throw new Exception(
         pht(
           'Parameter "%s" is not a list of transactions.',
           $transactions_key));
     }
 
     foreach ($xactions as $key => $xaction) {
       if (!is_array($xaction)) {
         throw new Exception(
           pht(
             'Parameter "%s" must contain a list of transaction descriptions, '.
             'but item with key "%s" is not a dictionary.',
             $transactions_key,
             $key));
       }
 
       if (!array_key_exists('type', $xaction)) {
         throw new Exception(
           pht(
             'Parameter "%s" must contain a list of transaction descriptions, '.
             'but item with key "%s" is missing a "type" field. Each '.
             'transaction must have a type field.',
             $transactions_key,
             $key));
       }
 
       if (!array_key_exists('value', $xaction)) {
         throw new Exception(
           pht(
             'Parameter "%s" must contain a list of transaction descriptions, '.
             'but item with key "%s" is missing a "value" field. Each '.
             'transaction must have a value field.',
             $transactions_key,
             $key));
       }
     }
 
     return $xactions;
   }
 
 
   /**
    * Generate transactions which can be applied from edit actions in a Conduit
    * request.
    *
-   * @param ConduitAPIRequest The request.
-   * @param list<wild> Raw conduit transactions.
-   * @param list<PhabricatorEditType> Supported edit types.
-   * @param PhabricatorApplicationTransaction Template transaction.
+   * @param ConduitAPIRequest $request The request.
+   * @param list<wild> $xactions Raw conduit transactions.
+   * @param list<PhabricatorEditType> $types Supported edit types.
+   * @param PhabricatorApplicationTransaction $template Template transaction.
    * @return list<PhabricatorApplicationTransaction> Generated transactions.
    * @task conduit
    */
   private function getConduitTransactions(
     ConduitAPIRequest $request,
     array $xactions,
     array $types,
     PhabricatorApplicationTransaction $template) {
 
     $viewer = $request->getUser();
     $results = array();
 
     foreach ($xactions as $key => $xaction) {
       $type = $xaction['type'];
       if (empty($types[$type])) {
         throw new Exception(
           pht(
             'Transaction with key "%s" has invalid type "%s". This type is '.
             'not recognized. Valid types are: %s.',
             $key,
             $type,
             implode(', ', array_keys($types))));
       }
     }
 
     if ($this->getIsCreate()) {
       $results[] = id(clone $template)
         ->setTransactionType(PhabricatorTransactions::TYPE_CREATE);
     }
 
     $is_strict = $request->getIsStrictlyTyped();
 
     foreach ($xactions as $xaction) {
       $type = $types[$xaction['type']];
 
       // Let the parameter type interpret the value. This allows you to
       // use usernames in list<user> fields, for example.
       $parameter_type = $type->getConduitParameterType();
 
       $parameter_type->setViewer($viewer);
 
       try {
         $value = $xaction['value'];
         $value = $parameter_type->getValue($xaction, 'value', $is_strict);
         $value = $type->getTransactionValueFromConduit($value);
         $xaction['value'] = $value;
       } catch (Exception $ex) {
         throw new PhutilProxyException(
           pht(
             'Exception when processing transaction of type "%s": %s',
             $xaction['type'],
             $ex->getMessage()),
           $ex);
       }
 
       $type_xactions = $type->generateTransactions(
         clone $template,
         $xaction);
 
       foreach ($type_xactions as $type_xaction) {
         $results[] = $type_xaction;
       }
     }
 
     return $results;
   }
 
 
   /**
    * @return map<string, PhabricatorEditType>
    * @task conduit
    */
   private function getConduitEditTypesFromFields(array $fields) {
     $types = array();
     foreach ($fields as $field) {
       $field_types = $field->getConduitEditTypes();
 
       if ($field_types === null) {
         continue;
       }
 
       foreach ($field_types as $field_type) {
         $types[$field_type->getEditType()] = $field_type;
       }
     }
     return $types;
   }
 
   public function getConduitEditTypes() {
     $config = $this->loadDefaultConfiguration();
     if (!$config) {
       return array();
     }
 
     $object = $this->newEditableObjectForDocumentation();
     $fields = $this->buildEditFields($object);
     return $this->getConduitEditTypesFromFields($fields);
   }
 
   final public static function getAllEditEngines() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getEngineKey')
       ->execute();
   }
 
   final public static function getByKey(PhabricatorUser $viewer, $key) {
     return id(new PhabricatorEditEngineQuery())
       ->setViewer($viewer)
       ->withEngineKeys(array($key))
       ->executeOne();
   }
 
   public function getIcon() {
     $application = $this->getApplication();
     return $application->getIcon();
   }
 
   private function loadUsableConfigurationsForCreate() {
     $viewer = $this->getViewer();
 
     $configs = id(new PhabricatorEditEngineConfigurationQuery())
       ->setViewer($viewer)
       ->withEngineKeys(array($this->getEngineKey()))
       ->withIsDefault(true)
       ->withIsDisabled(false)
       ->execute();
 
     $configs = msort($configs, 'getCreateSortKey');
 
     // Attach this specific engine to configurations we load so they can access
     // any runtime configuration. For example, this allows us to generate the
     // correct "Create Form" buttons when editing forms, see T12301.
     foreach ($configs as $config) {
       $config->attachEngine($this);
     }
 
     return $configs;
   }
 
   protected function getValidationExceptionShortMessage(
     PhabricatorApplicationTransactionValidationException $ex,
     PhabricatorEditField $field) {
 
     $xaction_type = $field->getTransactionType();
     if ($xaction_type === null) {
       return null;
     }
 
     return $ex->getShortMessage($xaction_type);
   }
 
   protected function getCreateNewObjectPolicy() {
     return PhabricatorPolicies::POLICY_USER;
   }
 
   private function requireCreateCapability() {
     PhabricatorPolicyFilter::requireCapability(
       $this->getViewer(),
       $this,
       PhabricatorPolicyCapability::CAN_EDIT);
   }
 
   private function hasCreateCapability() {
     return PhabricatorPolicyFilter::hasCapability(
       $this->getViewer(),
       $this,
       PhabricatorPolicyCapability::CAN_EDIT);
   }
 
   public function isCommentAction() {
     return ($this->getEditAction() == 'comment');
   }
 
   public function getEditAction() {
     $controller = $this->getController();
     $request = $controller->getRequest();
     return $request->getURIData('editAction');
   }
 
   protected function newCommentActionGroups() {
     return array();
   }
 
   protected function newAutomaticCommentTransactions($object) {
     return array();
   }
 
   protected function newCommentPreviewContent($object, array $xactions) {
     return null;
   }
 
 
 /* -(  Form Pages  )--------------------------------------------------------- */
 
 
   public function getSelectedPage() {
     return $this->page;
   }
 
 
   private function selectPage($object, $page_key) {
     $pages = $this->getPages($object);
 
     if (empty($pages[$page_key])) {
       return null;
     }
 
     $this->page = $pages[$page_key];
     return $this->page;
   }
 
 
   protected function newPages($object) {
     return array();
   }
 
 
   protected function getPages($object) {
     if ($this->pages === null) {
       $pages = $this->newPages($object);
 
       assert_instances_of($pages, 'PhabricatorEditPage');
       $pages = mpull($pages, null, 'getKey');
 
       $this->pages = $pages;
     }
 
     return $this->pages;
   }
 
   private function applyPageToFields($object, array $fields) {
     $pages = $this->getPages($object);
     if (!$pages) {
       return $fields;
     }
 
     if (!$this->getSelectedPage()) {
       return $fields;
     }
 
     $page_picks = array();
     $default_key = head($pages)->getKey();
     foreach ($pages as $page_key => $page) {
       foreach ($page->getFieldKeys() as $field_key) {
         $page_picks[$field_key] = $page_key;
       }
       if ($page->getIsDefault()) {
         $default_key = $page_key;
       }
     }
 
     $page_map = array_fill_keys(array_keys($pages), array());
     foreach ($fields as $field_key => $field) {
       if (isset($page_picks[$field_key])) {
         $page_map[$page_picks[$field_key]][$field_key] = $field;
         continue;
       }
 
       // TODO: Maybe let the field pick a page to associate itself with so
       // extensions can force themselves onto a particular page?
 
       $page_map[$default_key][$field_key] = $field;
     }
 
     $page = $this->getSelectedPage();
     if (!$page) {
       $page = head($pages);
     }
 
     $selected_key = $page->getKey();
     return $page_map[$selected_key];
   }
 
   protected function willApplyTransactions($object, array $xactions) {
     return $xactions;
   }
 
   protected function didApplyTransactions($object, array $xactions) {
     return;
   }
 
 
 /* -(  Bulk Edits  )--------------------------------------------------------- */
 
   final public function newBulkEditGroupMap() {
     $groups = $this->newBulkEditGroups();
 
     $map = array();
     foreach ($groups as $group) {
       $key = $group->getKey();
 
       if (isset($map[$key])) {
         throw new Exception(
           pht(
             'Two bulk edit groups have the same key ("%s"). Each bulk edit '.
             'group must have a unique key.',
             $key));
       }
 
       $map[$key] = $group;
     }
 
     if ($this->isEngineExtensible()) {
       $extensions = PhabricatorEditEngineExtension::getAllEnabledExtensions();
     } else {
       $extensions = array();
     }
 
     foreach ($extensions as $extension) {
       $extension_groups = $extension->newBulkEditGroups($this);
       foreach ($extension_groups as $group) {
         $key = $group->getKey();
 
         if (isset($map[$key])) {
           throw new Exception(
             pht(
               'Extension "%s" defines a bulk edit group with the same key '.
               '("%s") as the main editor or another extension. Each bulk '.
               'edit group must have a unique key.',
               get_class($extension),
               $key));
         }
 
         $map[$key] = $group;
       }
     }
 
     return $map;
   }
 
   protected function newBulkEditGroups() {
     return array(
       id(new PhabricatorBulkEditGroup())
         ->setKey('default')
         ->setLabel(pht('Primary Fields')),
       id(new PhabricatorBulkEditGroup())
         ->setKey('extension')
         ->setLabel(pht('Support Applications')),
     );
   }
 
   final public function newBulkEditMap() {
     $viewer = $this->getViewer();
 
     $config = $this->loadDefaultConfiguration();
     if (!$config) {
       throw new Exception(
         pht('No default edit engine configuration for bulk edit.'));
     }
 
     $object = $this->newEditableObject();
     $fields = $this->buildEditFields($object);
     $groups = $this->newBulkEditGroupMap();
 
     $edit_types = $this->getBulkEditTypesFromFields($fields);
 
     $map = array();
     foreach ($edit_types as $key => $type) {
       $bulk_type = $type->getBulkParameterType();
       if ($bulk_type === null) {
         continue;
       }
 
       $bulk_type->setViewer($viewer);
 
       $bulk_label = $type->getBulkEditLabel();
       if ($bulk_label === null) {
         continue;
       }
 
       $group_key = $type->getBulkEditGroupKey();
       if (!$group_key) {
         $group_key = 'default';
       }
 
       if (!isset($groups[$group_key])) {
         throw new Exception(
           pht(
             'Field "%s" has a bulk edit group key ("%s") with no '.
             'corresponding bulk edit group.',
             $key,
             $group_key));
       }
 
       $map[] = array(
         'label' => $bulk_label,
         'xaction' => $key,
         'group' => $group_key,
         'control' => array(
           'type' => $bulk_type->getPHUIXControlType(),
           'spec' => (object)$bulk_type->getPHUIXControlSpecification(),
         ),
       );
     }
 
     return $map;
   }
 
 
   final public function newRawBulkTransactions(array $xactions) {
     $config = $this->loadDefaultConfiguration();
     if (!$config) {
       throw new Exception(
         pht('No default edit engine configuration for bulk edit.'));
     }
 
     $object = $this->newEditableObject();
     $fields = $this->buildEditFields($object);
 
     $edit_types = $this->getBulkEditTypesFromFields($fields);
     $template = $object->getApplicationTransactionTemplate();
 
     $raw_xactions = array();
     foreach ($xactions as $key => $xaction) {
       PhutilTypeSpec::checkMap(
         $xaction,
         array(
           'type' => 'string',
           'value' => 'optional wild',
         ));
 
       $type = $xaction['type'];
       if (!isset($edit_types[$type])) {
         throw new Exception(
           pht(
             'Unsupported bulk edit type "%s".',
             $type));
       }
 
       $edit_type = $edit_types[$type];
 
       // Replace the edit type with the underlying transaction type. Usually
       // these are 1:1 and the transaction type just has more internal noise,
       // but it's possible that this isn't the case.
       $xaction['type'] = $edit_type->getTransactionType();
 
       $value = $xaction['value'];
       $value = $edit_type->getTransactionValueFromBulkEdit($value);
       $xaction['value'] = $value;
 
       $xaction_objects = $edit_type->generateTransactions(
         clone $template,
         $xaction);
 
       foreach ($xaction_objects as $xaction_object) {
         $raw_xaction = array(
           'type' => $xaction_object->getTransactionType(),
           'metadata' => $xaction_object->getMetadata(),
           'new' => $xaction_object->getNewValue(),
         );
 
         if ($xaction_object->hasOldValue()) {
           $raw_xaction['old'] = $xaction_object->getOldValue();
         }
 
         if ($xaction_object->hasComment()) {
           $comment = $xaction_object->getComment();
           $raw_xaction['comment'] = $comment->getContent();
         }
 
         $raw_xactions[] = $raw_xaction;
       }
     }
 
     return $raw_xactions;
   }
 
   private function getBulkEditTypesFromFields(array $fields) {
     $types = array();
 
     foreach ($fields as $field) {
       $field_types = $field->getBulkEditTypes();
 
       if ($field_types === null) {
         continue;
       }
 
       foreach ($field_types as $field_type) {
         $types[$field_type->getEditType()] = $field_type;
       }
     }
 
     return $types;
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   public function getPHID() {
     return get_class($this);
   }
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         return PhabricatorPolicies::getMostOpenPolicy();
       case PhabricatorPolicyCapability::CAN_EDIT:
         return $this->getCreateNewObjectPolicy();
     }
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return false;
   }
 
 }
diff --git a/src/applications/transactions/editfield/PhabricatorEditField.php b/src/applications/transactions/editfield/PhabricatorEditField.php
index 4c72d63214..4e17bf006c 100644
--- a/src/applications/transactions/editfield/PhabricatorEditField.php
+++ b/src/applications/transactions/editfield/PhabricatorEditField.php
@@ -1,929 +1,929 @@
 <?php
 
 abstract class PhabricatorEditField extends Phobject {
 
   private $key;
   private $viewer;
   private $label;
   private $aliases = array();
   private $value;
   private $initialValue;
   private $hasValue = false;
   private $object;
   private $transactionType;
   private $metadata = array();
   private $editTypeKey;
   private $isRequired;
   private $previewPanel;
   private $controlID;
   private $controlInstructions;
   private $bulkEditLabel;
   private $bulkEditGroupKey;
 
   private $description;
   private $conduitDescription;
   private $conduitDocumentation;
   private $conduitTypeDescription;
 
   private $commentActionLabel;
   private $commentActionValue;
   private $commentActionGroupKey;
   private $commentActionOrder = 1000;
   private $hasCommentActionValue;
 
   private $isLocked;
   private $isHidden;
 
   private $isPreview;
   private $isEditDefaults;
   private $isSubmittedForm;
   private $controlError;
   private $canApplyWithoutEditCapability = false;
 
   private $isReorderable = true;
   private $isDefaultable = true;
   private $isLockable = true;
   private $isCopyable = false;
   private $isFormField = true;
 
   private $conduitEditTypes;
   private $bulkEditTypes;
 
   public function setKey($key) {
     $this->key = $key;
     return $this;
   }
 
   public function getKey() {
     return $this->key;
   }
 
   public function setLabel($label) {
     $this->label = $label;
     return $this;
   }
 
   public function getLabel() {
     return $this->label;
   }
 
   public function setBulkEditLabel($bulk_edit_label) {
     $this->bulkEditLabel = $bulk_edit_label;
     return $this;
   }
 
   public function getBulkEditLabel() {
     return $this->bulkEditLabel;
   }
 
   public function setBulkEditGroupKey($key) {
     $this->bulkEditGroupKey = $key;
     return $this;
   }
 
   public function getBulkEditGroupKey() {
     return $this->bulkEditGroupKey;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   public function setAliases(array $aliases) {
     $this->aliases = $aliases;
     return $this;
   }
 
   public function getAliases() {
     return $this->aliases;
   }
 
   public function setObject($object) {
     $this->object = $object;
     return $this;
   }
 
   public function getObject() {
     return $this->object;
   }
 
   public function setIsLocked($is_locked) {
     $this->isLocked = $is_locked;
     return $this;
   }
 
   public function getIsLocked() {
     return $this->isLocked;
   }
 
   public function setIsPreview($preview) {
     $this->isPreview = $preview;
     return $this;
   }
 
   public function getIsPreview() {
     return $this->isPreview;
   }
 
   public function setIsReorderable($is_reorderable) {
     $this->isReorderable = $is_reorderable;
     return $this;
   }
 
   public function getIsReorderable() {
     return $this->isReorderable;
   }
 
   public function setIsFormField($is_form_field) {
     $this->isFormField = $is_form_field;
     return $this;
   }
 
   public function getIsFormField() {
     return $this->isFormField;
   }
 
   public function setDescription($description) {
     $this->description = $description;
     return $this;
   }
 
   public function getDescription() {
     return $this->description;
   }
 
   public function setConduitDescription($conduit_description) {
     $this->conduitDescription = $conduit_description;
     return $this;
   }
 
   public function getConduitDescription() {
     if ($this->conduitDescription === null) {
       return $this->getDescription();
     }
     return $this->conduitDescription;
   }
 
   /**
    * Set the Conduit documentation in raw Remarkup.
    * @param  string|null $conduit_documentation
    * @return self
    */
   public function setConduitDocumentation($conduit_documentation) {
     $this->conduitDocumentation = $conduit_documentation;
     return $this;
   }
 
   /**
    * Get the Conduit documentation in raw Remarkup.
    * @return string|null
    */
   public function getConduitDocumentation() {
     return $this->conduitDocumentation;
   }
 
   public function setConduitTypeDescription($conduit_type_description) {
     $this->conduitTypeDescription = $conduit_type_description;
     return $this;
   }
 
   public function getConduitTypeDescription() {
     return $this->conduitTypeDescription;
   }
 
   public function setIsEditDefaults($is_edit_defaults) {
     $this->isEditDefaults = $is_edit_defaults;
     return $this;
   }
 
   public function getIsEditDefaults() {
     return $this->isEditDefaults;
   }
 
   public function setIsDefaultable($is_defaultable) {
     $this->isDefaultable = $is_defaultable;
     return $this;
   }
 
   public function getIsDefaultable() {
     return $this->isDefaultable;
   }
 
   public function setIsLockable($is_lockable) {
     $this->isLockable = $is_lockable;
     return $this;
   }
 
   public function getIsLockable() {
     return $this->isLockable;
   }
 
   public function setIsHidden($is_hidden) {
     $this->isHidden = $is_hidden;
     return $this;
   }
 
   public function getIsHidden() {
     return $this->isHidden;
   }
 
   public function setIsCopyable($is_copyable) {
     $this->isCopyable = $is_copyable;
     return $this;
   }
 
   public function getIsCopyable() {
     return $this->isCopyable;
   }
 
   public function setIsSubmittedForm($is_submitted) {
     $this->isSubmittedForm = $is_submitted;
     return $this;
   }
 
   public function getIsSubmittedForm() {
     return $this->isSubmittedForm;
   }
 
   public function setIsRequired($is_required) {
     $this->isRequired = $is_required;
     return $this;
   }
 
   public function getIsRequired() {
     return $this->isRequired;
   }
 
   public function setControlError($control_error) {
     $this->controlError = $control_error;
     return $this;
   }
 
   public function getControlError() {
     return $this->controlError;
   }
 
   public function setCommentActionLabel($label) {
     $this->commentActionLabel = $label;
     return $this;
   }
 
   public function getCommentActionLabel() {
     return $this->commentActionLabel;
   }
 
   public function setCommentActionGroupKey($key) {
     $this->commentActionGroupKey = $key;
     return $this;
   }
 
   public function getCommentActionGroupKey() {
     return $this->commentActionGroupKey;
   }
 
   public function setCommentActionOrder($order) {
     $this->commentActionOrder = $order;
     return $this;
   }
 
   public function getCommentActionOrder() {
     return $this->commentActionOrder;
   }
 
   public function setCommentActionValue($comment_action_value) {
     $this->hasCommentActionValue = true;
     $this->commentActionValue = $comment_action_value;
     return $this;
   }
 
   public function getCommentActionValue() {
     return $this->commentActionValue;
   }
 
   public function setPreviewPanel(PHUIRemarkupPreviewPanel $preview_panel) {
     $this->previewPanel = $preview_panel;
     return $this;
   }
 
   public function getPreviewPanel() {
     if ($this->getIsHidden()) {
       return null;
     }
 
     if ($this->getIsLocked()) {
       return null;
     }
 
     return $this->previewPanel;
   }
 
   public function setControlInstructions($control_instructions) {
     $this->controlInstructions = $control_instructions;
     return $this;
   }
 
   public function getControlInstructions() {
     return $this->controlInstructions;
   }
 
   public function setCanApplyWithoutEditCapability($can_apply) {
     $this->canApplyWithoutEditCapability = $can_apply;
     return $this;
   }
 
   public function getCanApplyWithoutEditCapability() {
     return $this->canApplyWithoutEditCapability;
   }
 
   protected function newControl() {
     throw new PhutilMethodNotImplementedException();
   }
 
   protected function buildControl() {
     if (!$this->getIsFormField()) {
       return null;
     }
 
     $control = $this->newControl();
     if ($control === null) {
       return null;
     }
 
     $control
       ->setValue($this->getValueForControl())
       ->setName($this->getKey());
 
     if (!$control->getLabel()) {
       $control->setLabel($this->getLabel());
     }
 
     if ($this->getIsSubmittedForm()) {
       $error = $this->getControlError();
       if ($error !== null) {
         $control->setError($error);
       }
     } else if ($this->getIsRequired()) {
       $control->setError(true);
     }
 
     return $control;
   }
 
   public function getControlID() {
     if (!$this->controlID) {
       $this->controlID = celerity_generate_unique_node_id();
     }
     return $this->controlID;
   }
 
   protected function renderControl() {
     $control = $this->buildControl();
     if ($control === null) {
       return null;
     }
 
     if ($this->getIsPreview()) {
       $disabled = true;
       $hidden = false;
     } else if ($this->getIsEditDefaults()) {
       $disabled = false;
       $hidden = false;
     } else {
       $disabled = $this->getIsLocked();
       $hidden = $this->getIsHidden();
     }
 
     if ($hidden) {
       return null;
     }
 
     $control->setDisabled($disabled);
 
     if ($this->controlID) {
       $control->setID($this->controlID);
     }
 
     return $control;
   }
 
   public function appendToForm(AphrontFormView $form) {
     $control = $this->renderControl();
     if ($control !== null) {
 
       if ($this->getIsPreview()) {
         if ($this->getIsHidden()) {
           $control
             ->addClass('aphront-form-preview-hidden')
             ->setError(pht('Hidden'));
         } else if ($this->getIsLocked()) {
           $control
             ->setError(pht('Locked'));
         }
       }
 
       $instructions = $this->getControlInstructions();
       if (phutil_nonempty_string($instructions)) {
         $form->appendRemarkupInstructions($instructions);
       }
 
       $form->appendControl($control);
     }
     return $this;
   }
 
   protected function getValueForControl() {
     return $this->getValue();
   }
 
   public function getValueForDefaults() {
     $value = $this->getValue();
 
     // By default, just treat the empty string like `null` since they're
     // equivalent for almost all fields and this reduces the number of
     // meaningless transactions we generate when adjusting defaults.
     if ($value === '') {
       return null;
     }
 
     return $value;
   }
 
   protected function getValue() {
     return $this->value;
   }
 
   public function setValue($value) {
     $this->hasValue = true;
     $this->value = $value;
 
     // If we don't have an initial value set yet, use the value as the
     // initial value.
     $initial_value = $this->getInitialValue();
     if ($initial_value === null) {
       $this->initialValue = $value;
     }
 
     return $this;
   }
 
   public function setMetadataValue($key, $value) {
     $this->metadata[$key] = $value;
     return $this;
   }
 
   public function getMetadata() {
     return $this->metadata;
   }
 
   public function getValueForTransaction() {
     return $this->getValue();
   }
 
   public function getTransactionType() {
     return $this->transactionType;
   }
 
   public function setTransactionType($type) {
     $this->transactionType = $type;
     return $this;
   }
 
   public function readValueFromRequest(AphrontRequest $request) {
     $check = $this->getAllReadValueFromRequestKeys();
     foreach ($check as $key) {
       if (!$this->getValueExistsInRequest($request, $key)) {
         continue;
       }
 
       $this->value = $this->getValueFromRequest($request, $key);
       break;
     }
     return $this;
   }
 
   public function readValueFromComment($value) {
     $this->value = $this->getValueFromComment($value);
     return $this;
   }
 
   protected function getValueFromComment($value) {
     return $value;
   }
 
   public function getAllReadValueFromRequestKeys() {
     $keys = array();
 
     $keys[] = $this->getKey();
     foreach ($this->getAliases() as $alias) {
       $keys[] = $alias;
     }
 
     return $keys;
   }
 
   public function readDefaultValueFromConfiguration($value) {
     $this->value = $this->getDefaultValueFromConfiguration($value);
     return $this;
   }
 
   protected function getDefaultValueFromConfiguration($value) {
     return $value;
   }
 
   protected function getValueFromObject($object) {
     if ($this->hasValue) {
       return $this->value;
     } else {
       return $this->getDefaultValue();
     }
   }
 
   protected function getValueExistsInRequest(AphrontRequest $request, $key) {
     return $this->getHTTPParameterValueExists($request, $key);
   }
 
   protected function getValueFromRequest(AphrontRequest $request, $key) {
     return $this->getHTTPParameterValue($request, $key);
   }
 
   public function readValueFromField(PhabricatorEditField $other) {
     $this->value = $this->getValueFromField($other);
     return $this;
   }
 
   protected function getValueFromField(PhabricatorEditField $other) {
     return $other->getValue();
   }
 
 
   /**
    * Read and return the value the object had when the user first loaded the
    * form.
    *
    * This is the initial value from the user's point of view when they started
    * the edit process, and used primarily to prevent race conditions for fields
    * like "Projects" and "Subscribers" that use tokenizers and support edge
    * transactions.
    *
    * Most fields do not need to store these values or deal with initial value
    * handling.
    *
-   * @param AphrontRequest Request to read from.
-   * @param string Key to read.
+   * @param AphrontRequest $request Request to read from.
+   * @param string $key Key to read.
    * @return wild Value read from request.
    */
   protected function getInitialValueFromSubmit(AphrontRequest $request, $key) {
     return null;
   }
 
   public function getInitialValue() {
     return $this->initialValue;
   }
 
   public function setInitialValue($initial_value) {
     $this->initialValue = $initial_value;
     return $this;
   }
 
   public function readValueFromSubmit(AphrontRequest $request) {
     $key = $this->getKey();
     if ($this->getValueExistsInSubmit($request, $key)) {
       $value = $this->getValueFromSubmit($request, $key);
     } else {
       $value = $this->getDefaultValue();
     }
     $this->value = $value;
 
     $initial_value = $this->getInitialValueFromSubmit($request, $key);
     $this->initialValue = $initial_value;
 
     return $this;
   }
 
   protected function getValueExistsInSubmit(AphrontRequest $request, $key) {
     return $this->getHTTPParameterValueExists($request, $key);
   }
 
   protected function getValueFromSubmit(AphrontRequest $request, $key) {
     return $this->getHTTPParameterValue($request, $key);
   }
 
   protected function getHTTPParameterValueExists(
     AphrontRequest $request,
     $key) {
     $type = $this->getHTTPParameterType();
 
     if ($type) {
       return $type->getExists($request, $key);
     }
 
     return false;
   }
 
   protected function getHTTPParameterValue($request, $key) {
     $type = $this->getHTTPParameterType();
 
     if ($type) {
       return $type->getValue($request, $key);
     }
 
     return null;
   }
 
   protected function getDefaultValue() {
     $type = $this->getHTTPParameterType();
 
     if ($type) {
       return $type->getDefaultValue();
     }
 
     return null;
   }
 
   final public function getHTTPParameterType() {
     if (!$this->getIsFormField()) {
       return null;
     }
 
     $type = $this->newHTTPParameterType();
 
     if ($type) {
       $type->setViewer($this->getViewer());
     }
 
     return $type;
   }
 
   protected function newHTTPParameterType() {
     return new AphrontStringHTTPParameterType();
   }
 
   protected function getBulkParameterType() {
     $type = $this->newBulkParameterType();
 
     if (!$type) {
       return null;
     }
 
     $type
       ->setField($this)
       ->setViewer($this->getViewer());
 
     return $type;
   }
 
   protected function newBulkParameterType() {
     return null;
   }
 
   public function getConduitParameterType() {
     $type = $this->newConduitParameterType();
 
     if (!$type) {
       return null;
     }
 
     $type->setViewer($this->getViewer());
 
     return $type;
   }
 
   abstract protected function newConduitParameterType();
 
   public function setEditTypeKey($edit_type_key) {
     $this->editTypeKey = $edit_type_key;
     return $this;
   }
 
   public function getEditTypeKey() {
     if ($this->editTypeKey === null) {
       return $this->getKey();
     }
     return $this->editTypeKey;
   }
 
   protected function newEditType() {
     return new PhabricatorSimpleEditType();
   }
 
   protected function getEditType() {
     $transaction_type = $this->getTransactionType();
     if ($transaction_type === null) {
       return null;
     }
 
     $edit_type = $this->newEditType();
     if (!$edit_type) {
       return null;
     }
 
     $type_key = $this->getEditTypeKey();
 
     $edit_type
       ->setEditField($this)
       ->setTransactionType($transaction_type)
       ->setEditType($type_key)
       ->setMetadata($this->getMetadata());
 
     if (!$edit_type->getConduitParameterType()) {
       $conduit_parameter = $this->getConduitParameterType();
       if ($conduit_parameter) {
         $edit_type->setConduitParameterType($conduit_parameter);
       }
     }
 
     if (!$edit_type->getBulkParameterType()) {
       $bulk_parameter = $this->getBulkParameterType();
       if ($bulk_parameter) {
         $edit_type->setBulkParameterType($bulk_parameter);
       }
     }
 
     return $edit_type;
   }
 
   final public function getConduitEditTypes() {
     if ($this->conduitEditTypes === null) {
       $edit_types = $this->newConduitEditTypes();
       $edit_types = mpull($edit_types, null, 'getEditType');
       $this->conduitEditTypes = $edit_types;
     }
 
     return $this->conduitEditTypes;
   }
 
   final public function getConduitEditType($key) {
     $edit_types = $this->getConduitEditTypes();
 
     if (empty($edit_types[$key])) {
       throw new Exception(
         pht(
           'This EditField does not provide a Conduit EditType with key "%s".',
           $key));
     }
 
     return $edit_types[$key];
   }
 
   protected function newConduitEditTypes() {
     $edit_type = $this->getEditType();
 
     if (!$edit_type) {
       return array();
     }
 
     return array($edit_type);
   }
 
   final public function getBulkEditTypes() {
     if ($this->bulkEditTypes === null) {
       $edit_types = $this->newBulkEditTypes();
       $edit_types = mpull($edit_types, null, 'getEditType');
       $this->bulkEditTypes = $edit_types;
     }
 
     return $this->bulkEditTypes;
   }
 
   final public function getBulkEditType($key) {
     $edit_types = $this->getBulkEditTypes();
 
     if (empty($edit_types[$key])) {
       throw new Exception(
         pht(
           'This EditField does not provide a Bulk EditType with key "%s".',
           $key));
     }
 
     return $edit_types[$key];
   }
 
   protected function newBulkEditTypes() {
     $edit_type = $this->getEditType();
 
     if (!$edit_type) {
       return array();
     }
 
     return array($edit_type);
   }
 
   public function getCommentAction() {
     $label = $this->getCommentActionLabel();
     if ($label === null) {
       return null;
     }
 
     $action = $this->newCommentAction();
     if ($action === null) {
       return null;
     }
 
     if ($this->hasCommentActionValue) {
       $value = $this->getCommentActionValue();
     } else {
       $value = $this->getValue();
     }
 
     $action
       ->setKey($this->getKey())
       ->setLabel($label)
       ->setValue($this->getValueForCommentAction($value))
       ->setOrder($this->getCommentActionOrder())
       ->setGroupKey($this->getCommentActionGroupKey());
 
     return $action;
   }
 
   protected function newCommentAction() {
     return null;
   }
 
   protected function getValueForCommentAction($value) {
     return $value;
   }
 
   public function shouldGenerateTransactionsFromSubmit() {
     if (!$this->getIsFormField()) {
       return false;
     }
 
     $edit_type = $this->getEditType();
     if (!$edit_type) {
       return false;
     }
 
     return true;
   }
 
   public function shouldReadValueFromRequest() {
     if (!$this->getIsFormField()) {
       return false;
     }
 
     if ($this->getIsLocked()) {
       return false;
     }
 
     if ($this->getIsHidden()) {
       return false;
     }
 
     return true;
   }
 
   public function shouldReadValueFromSubmit() {
     if (!$this->getIsFormField()) {
       return false;
     }
 
     if ($this->getIsLocked()) {
       return false;
     }
 
     if ($this->getIsHidden()) {
       return false;
     }
 
     return true;
   }
 
   public function shouldGenerateTransactionsFromComment() {
     if (!$this->getCommentActionLabel()) {
       return false;
     }
 
     if ($this->getIsLocked()) {
       return false;
     }
 
     if ($this->getIsHidden()) {
       return false;
     }
 
     return true;
   }
 
   public function generateTransactions(
     PhabricatorApplicationTransaction $template,
     array $spec) {
 
     $edit_type = $this->getEditType();
     if (!$edit_type) {
       throw new Exception(
         pht(
           'EditField (with key "%s", of class "%s") is generating '.
           'transactions, but has no EditType.',
           $this->getKey(),
           get_class($this)));
     }
 
     return $edit_type->generateTransactions($template, $spec);
   }
 
 }
diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
index b643279bc8..b759e58c1d 100644
--- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
+++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
@@ -1,5757 +1,5759 @@
 <?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 $raiseWarnings;
   private $parentMessageID;
   private $heraldAdapter;
   private $heraldTranscript;
   private $subscribers;
   private $unmentionablePHIDMap = array();
   private $transactionGroupID;
   private $applicationEmail;
 
   private $isPreview;
   private $isHeraldEditor;
   private $isInverseEdgeEditor;
   private $actingAsPHID;
 
   private $heraldEmailPHIDs = array();
   private $heraldForcedEmailPHIDs = array();
   private $heraldHeader;
   private $mailToPHIDs = array();
   private $mailCCPHIDs = array();
   private $feedNotifyPHIDs = array();
   private $feedRelatedPHIDs = array();
   private $feedShouldPublish = false;
   private $mailShouldSend = false;
   private $modularTypes;
   private $silent;
   private $mustEncrypt = array();
   private $stampTemplates = array();
   private $mailStamps = array();
   private $oldTo = array();
   private $oldCC = array();
   private $mailRemovedPHIDs = array();
   private $mailUnexpandablePHIDs = array();
   private $mailMutedPHIDs = array();
   private $webhookMap = array();
 
   private $transactionQueue = array();
   private $sendHistory = false;
   private $shouldRequireMFA = false;
   private $hasRequiredMFA = false;
   private $request;
   private $cancelURI;
   private $extensions;
 
   private $parentEditor;
   private $subEditors = array();
   private $publishableObject;
   private $publishableTransactions;
 
   const STORAGE_ENCODING_BINARY = 'binary';
 
   /**
    * 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.
+   * @param bool $continue 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.
+   * @param bool $continue_on_missing_fields 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;
   }
 
   public function getMentionedPHIDs() {
     return $this->mentionedPHIDs;
   }
 
   public function setIsPreview($is_preview) {
     $this->isPreview = $is_preview;
     return $this;
   }
 
   public function getIsPreview() {
     return $this->isPreview;
   }
 
   public function setIsSilent($silent) {
     $this->silent = $silent;
     return $this;
   }
 
   public function getIsSilent() {
     return $this->silent;
   }
 
   public function getMustEncrypt() {
     return $this->mustEncrypt;
   }
 
   public function getHeraldRuleMonograms() {
     // Convert the stored "<123>, <456>" string into a list: "H123", "H456".
     $list = phutil_string_cast($this->heraldHeader);
     $list = preg_split('/[, ]+/', $list);
 
     foreach ($list as $key => $item) {
       $item = trim($item, '<>');
 
       if (!is_numeric($item)) {
         unset($list[$key]);
         continue;
       }
 
       $list[$key] = 'H'.$item;
     }
 
     return $list;
   }
 
   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;
   }
 
   public function addUnmentionablePHIDs(array $phids) {
     foreach ($phids as $phid) {
       $this->unmentionablePHIDMap[$phid] = true;
     }
     return $this;
   }
 
   private 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 setRaiseWarnings($raise_warnings) {
     $this->raiseWarnings = $raise_warnings;
     return $this;
   }
 
   public function getRaiseWarnings() {
     return $this->raiseWarnings;
   }
 
   public function setShouldRequireMFA($should_require_mfa) {
     if ($this->hasRequiredMFA) {
       throw new Exception(
         pht(
           'Call to setShouldRequireMFA() is too late: this Editor has already '.
           'checked for MFA requirements.'));
     }
 
     $this->shouldRequireMFA = $should_require_mfa;
     return $this;
   }
 
   public function getShouldRequireMFA() {
     return $this->shouldRequireMFA;
   }
 
   public function getTransactionTypesForObject($object) {
     $old = $this->object;
     try {
       $this->object = $object;
       $result = $this->getTransactionTypes();
       $this->object = $old;
     } catch (Exception $ex) {
       $this->object = $old;
       throw $ex;
     }
     return $result;
   }
 
   public function getTransactionTypes() {
     $types = array();
 
     $types[] = PhabricatorTransactions::TYPE_CREATE;
     $types[] = PhabricatorTransactions::TYPE_HISTORY;
 
     $types[] = PhabricatorTransactions::TYPE_FILE;
 
     if ($this->object instanceof PhabricatorEditEngineSubtypeInterface) {
       $types[] = PhabricatorTransactions::TYPE_SUBTYPE;
     }
 
     if ($this->object instanceof PhabricatorSubscribableInterface) {
       $types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS;
     }
 
     if ($this->object instanceof PhabricatorCustomFieldInterface) {
       $types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD;
     }
 
     if ($this->object instanceof PhabricatorTokenReceiverInterface) {
       $types[] = PhabricatorTransactions::TYPE_TOKEN;
     }
 
     if ($this->object instanceof PhabricatorProjectInterface ||
         $this->object instanceof PhabricatorMentionableInterface) {
       $types[] = PhabricatorTransactions::TYPE_EDGE;
     }
 
     if ($this->object instanceof PhabricatorSpacesInterface) {
       $types[] = PhabricatorTransactions::TYPE_SPACE;
     }
 
     $types[] = PhabricatorTransactions::TYPE_MFA;
 
     $template = $this->object->getApplicationTransactionTemplate();
     if ($template instanceof PhabricatorModularTransaction) {
       $xtypes = $template->newModularTransactionTypes();
       foreach ($xtypes as $xtype) {
         $types[] = $xtype->getTransactionTypeConstant();
       }
     }
 
     if ($template) {
       $comment = $template->getApplicationTransactionCommentObject();
       if ($comment) {
         $types[] = PhabricatorTransactions::TYPE_COMMENT;
       }
     }
 
     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);
 
     // Apply an optional transformation to convert "external" transaction
     // values (provided by APIs) into "internal" values.
 
     $old = $xaction->getOldValue();
     $new = $xaction->getNewValue();
 
     $type = $xaction->getTransactionType();
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       $xtype = clone $xtype;
       $xtype->setStorage($xaction);
 
 
       // TODO: Provide a modular hook for modern transactions to do a
       // transformation.
       list($old, $new) = array($old, $new);
 
       return;
     } else {
       switch ($type) {
         case PhabricatorTransactions::TYPE_FILE:
           list($old, $new) = $this->newFileTransactionInternalValues(
             $object,
             $xaction,
             $old,
             $new);
           break;
       }
     }
 
     $xaction->setOldValue($old);
     $xaction->setNewValue($new);
   }
 
   private function newFileTransactionInternalValues(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction,
     $old,
     $new) {
 
     $old_map = array();
 
     if (!$this->getIsNewObject()) {
       $phid = $object->getPHID();
 
       $attachment_table = new PhabricatorFileAttachment();
       $attachment_conn = $attachment_table->establishConnection('w');
 
       $rows = queryfx_all(
         $attachment_conn,
         'SELECT filePHID, attachmentMode FROM %R WHERE objectPHID = %s',
         $attachment_table,
         $phid);
       $old_map = ipull($rows, 'attachmentMode', 'filePHID');
     }
 
     $mode_ref = PhabricatorFileAttachment::MODE_REFERENCE;
     $mode_detach = PhabricatorFileAttachment::MODE_DETACH;
 
     $new_map = $old_map;
 
     foreach ($new as $file_phid => $attachment_mode) {
       $is_ref = ($attachment_mode === $mode_ref);
       $is_detach = ($attachment_mode === $mode_detach);
 
       if ($is_detach) {
         unset($new_map[$file_phid]);
         continue;
       }
 
       $old_mode = idx($old_map, $file_phid);
 
       // If we're adding a reference to a file but it is already attached,
       // don't touch it.
 
       if ($is_ref) {
         if ($old_mode !== null) {
           continue;
         }
       }
 
       $new_map[$file_phid] = $attachment_mode;
     }
 
     foreach (array_keys($old_map + $new_map) as $key) {
       if (isset($old_map[$key]) && isset($new_map[$key])) {
         if ($old_map[$key] === $new_map[$key]) {
           unset($old_map[$key]);
           unset($new_map[$key]);
         }
       }
     }
 
     return array($old_map, $new_map);
   }
 
   private function getTransactionOldValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     $type = $xaction->getTransactionType();
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       $xtype = clone $xtype;
       $xtype->setStorage($xaction);
       return $xtype->generateOldValue($object);
     }
 
     switch ($type) {
       case PhabricatorTransactions::TYPE_CREATE:
       case PhabricatorTransactions::TYPE_HISTORY:
         return null;
       case PhabricatorTransactions::TYPE_SUBTYPE:
         return $object->getEditEngineSubtype();
       case PhabricatorTransactions::TYPE_MFA:
         return null;
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return array_values($this->subscribers);
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         if ($this->getIsNewObject()) {
           return null;
         }
         return $object->getViewPolicy();
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         if ($this->getIsNewObject()) {
           return null;
         }
         return $object->getEditPolicy();
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         if ($this->getIsNewObject()) {
           return null;
         }
         return $object->getJoinPolicy();
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
         if ($this->getIsNewObject()) {
           return null;
         }
         return $object->getInteractPolicy();
       case PhabricatorTransactions::TYPE_SPACE:
         if ($this->getIsNewObject()) {
           return null;
         }
 
         $space_phid = $object->getSpacePHID();
         if ($space_phid === null) {
           $default_space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
           if ($default_space) {
             $space_phid = $default_space->getPHID();
           }
         }
 
         return $space_phid;
       case PhabricatorTransactions::TYPE_EDGE:
         $edge_type = $xaction->getMetadataValue('edge:type');
         if (!$edge_type) {
           throw new Exception(
             pht(
               "Edge transaction has no '%s'!",
               'edge:type'));
         }
 
         // See T13082. If this is an inverse edit, the parent editor has
         // already populated the transaction values correctly.
         if ($this->getIsInverseEdgeEditor()) {
           return $xaction->getOldValue();
         }
 
         $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;
       case PhabricatorTransactions::TYPE_FILE:
         return null;
       default:
         return $this->getCustomTransactionOldValue($object, $xaction);
     }
   }
 
   private function getTransactionNewValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     $type = $xaction->getTransactionType();
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       $xtype = clone $xtype;
       $xtype->setStorage($xaction);
       return $xtype->generateNewValue($object, $xaction->getNewValue());
     }
 
     switch ($type) {
       case PhabricatorTransactions::TYPE_CREATE:
         return null;
       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_INTERACT_POLICY:
       case PhabricatorTransactions::TYPE_TOKEN:
       case PhabricatorTransactions::TYPE_INLINESTATE:
       case PhabricatorTransactions::TYPE_SUBTYPE:
       case PhabricatorTransactions::TYPE_HISTORY:
       case PhabricatorTransactions::TYPE_FILE:
         return $xaction->getNewValue();
       case PhabricatorTransactions::TYPE_MFA:
         return true;
       case PhabricatorTransactions::TYPE_SPACE:
         $space_phid = $xaction->getNewValue();
         if (!phutil_nonempty_string($space_phid)) {
           // If an install has no Spaces or the Spaces controls are not visible
           // to the viewer, we might end up with the empty string here instead
           // of a strict `null`, because some controller just used `getStr()`
           // to read the space PHID from the request.
           // Just make this work like callers might reasonably expect so we
           // don't need to handle this specially in every EditController.
           return $this->getActor()->getDefaultSpacePHID();
         } else {
           return $space_phid;
         }
       case PhabricatorTransactions::TYPE_EDGE:
         // See T13082. If this is an inverse edit, the parent editor has
         // already populated appropriate transaction values.
         if ($this->getIsInverseEdgeEditor()) {
           return $xaction->getNewValue();
         }
 
         $new_value = $this->getEdgeTransactionNewValue($xaction);
 
         $edge_type = $xaction->getMetadataValue('edge:type');
         $type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
         if ($edge_type == $type_project) {
           $new_value = $this->applyProjectConflictRules($new_value);
         }
 
         return $new_value;
       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_CREATE:
       case PhabricatorTransactions::TYPE_HISTORY:
         return true;
       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;
     }
 
     $type = $xaction->getTransactionType();
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       return $xtype->getTransactionHasEffect(
         $object,
         $xaction->getOldValue(),
         $xaction->getNewValue());
     }
 
     if ($xaction->hasComment()) {
       return true;
     }
 
     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) {
 
     $type = $xaction->getTransactionType();
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       $xtype = clone $xtype;
       $xtype->setStorage($xaction);
       return $xtype->applyInternalEffects($object, $xaction->getNewValue());
     }
 
     switch ($type) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getCustomFieldForTransaction($object, $xaction);
         return $field->applyApplicationTransactionInternalEffects($xaction);
       case PhabricatorTransactions::TYPE_CREATE:
       case PhabricatorTransactions::TYPE_HISTORY:
       case PhabricatorTransactions::TYPE_SUBTYPE:
       case PhabricatorTransactions::TYPE_MFA:
       case PhabricatorTransactions::TYPE_TOKEN:
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
       case PhabricatorTransactions::TYPE_INLINESTATE:
       case PhabricatorTransactions::TYPE_EDGE:
       case PhabricatorTransactions::TYPE_SPACE:
       case PhabricatorTransactions::TYPE_COMMENT:
       case PhabricatorTransactions::TYPE_FILE:
         return $this->applyBuiltinInternalTransaction($object, $xaction);
     }
 
     return $this->applyCustomInternalTransaction($object, $xaction);
   }
 
   private function applyExternalEffects(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     $type = $xaction->getTransactionType();
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       $xtype = clone $xtype;
       $xtype->setStorage($xaction);
       return $xtype->applyExternalEffects($object, $xaction->getNewValue());
     }
 
     switch ($type) {
       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_CREATE:
       case PhabricatorTransactions::TYPE_HISTORY:
       case PhabricatorTransactions::TYPE_SUBTYPE:
       case PhabricatorTransactions::TYPE_MFA:
       case PhabricatorTransactions::TYPE_EDGE:
       case PhabricatorTransactions::TYPE_TOKEN:
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
       case PhabricatorTransactions::TYPE_INLINESTATE:
       case PhabricatorTransactions::TYPE_SPACE:
       case PhabricatorTransactions::TYPE_COMMENT:
       case PhabricatorTransactions::TYPE_FILE:
         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;
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
         $object->setInteractPolicy($xaction->getNewValue());
         break;
       case PhabricatorTransactions::TYPE_SPACE:
         $object->setSpacePHID($xaction->getNewValue());
         break;
       case PhabricatorTransactions::TYPE_SUBTYPE:
         $object->setEditEngineSubtype($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');
 
         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();
 
         $this->updateWorkboardColumns($object, $const, $old, $new);
         break;
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_SPACE:
         $this->scrambleFileSecrets($object);
         break;
       case PhabricatorTransactions::TYPE_HISTORY:
         $this->sendHistory = true;
         break;
       case PhabricatorTransactions::TYPE_FILE:
         $this->applyFileTransaction($object, $xaction);
         break;
     }
   }
 
   private function applyFileTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     $old_map = $xaction->getOldValue();
     $new_map = $xaction->getNewValue();
 
     $add_phids = array();
     $rem_phids = array();
 
     foreach ($new_map as $phid => $mode) {
       $add_phids[$phid] = $mode;
     }
 
     foreach ($old_map as $phid => $mode) {
       if (!isset($new_map[$phid])) {
         $rem_phids[] = $phid;
       }
     }
 
     $now = PhabricatorTime::getNow();
     $object_phid = $object->getPHID();
     $attacher_phid = $this->getActingAsPHID();
 
     $attachment_table = new PhabricatorFileAttachment();
     $attachment_conn = $attachment_table->establishConnection('w');
 
     $add_sql = array();
     foreach ($add_phids as $add_phid => $add_mode) {
       $add_sql[] = qsprintf(
         $attachment_conn,
         '(%s, %s, %s, %ns, %d, %d)',
         $object_phid,
         $add_phid,
         $add_mode,
         $attacher_phid,
         $now,
         $now);
     }
 
     $rem_sql = array();
     foreach ($rem_phids as $rem_phid) {
       $rem_sql[] = qsprintf(
         $attachment_conn,
         '%s',
         $rem_phid);
     }
 
     foreach (PhabricatorLiskDAO::chunkSQL($add_sql) as $chunk) {
       queryfx(
         $attachment_conn,
         'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
             attacherPHID, dateCreated, dateModified)
           VALUES %LQ
           ON DUPLICATE KEY UPDATE
             attachmentMode = VALUES(attachmentMode),
             attacherPHID = VALUES(attacherPHID),
             dateModified = VALUES(dateModified)',
         $attachment_table,
         $chunk);
     }
 
     foreach (PhabricatorLiskDAO::chunkSQL($rem_sql) as $chunk) {
       queryfx(
         $attachment_conn,
         'DELETE FROM %R WHERE objectPHID = %s AND filePHID in (%LQ)',
         $attachment_table,
         $object_phid,
         $chunk);
     }
   }
 
   /**
    * 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());
     }
 
     // If the transaction already has an explicit author PHID, allow it to
     // stand. This is used by applications like Owners that hook into the
     // post-apply change pipeline.
     if (!$xaction->getAuthorPHID()) {
       $xaction->setAuthorPHID($this->getActingAsPHID());
     }
 
     $xaction->setContentSource($this->getContentSource());
     $xaction->attachViewer($actor);
     $xaction->attachObject($object);
 
     if ($object->getPHID()) {
       $xaction->setObjectPHID($object->getPHID());
     }
 
     if ($this->getIsSilent()) {
       $xaction->setIsSilentTransaction(true);
     }
 
     return $xaction;
   }
 
   protected function didApplyInternalEffects(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return $xactions;
   }
 
   protected function applyFinalEffects(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return $xactions;
   }
 
   final protected function didCommitTransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     foreach ($xactions as $xaction) {
       $type = $xaction->getTransactionType();
 
       // See T13082. When we're writing edges that imply corresponding inverse
       // transactions, apply those inverse transactions now. We have to wait
       // until the object we're editing (with this editor) has committed its
       // transactions to do this. If we don't, the inverse editor may race,
       // build a mail before we actually commit this object, and render "alice
       // added an edge: Unknown Object".
 
       if ($type === PhabricatorTransactions::TYPE_EDGE) {
         // Don't do anything if we're already an inverse edge editor.
         if ($this->getIsInverseEdgeEditor()) {
           continue;
         }
 
         $edge_const = $xaction->getMetadataValue('edge:type');
         $edge_type = PhabricatorEdgeType::getByConstant($edge_const);
         if ($edge_type->shouldWriteInverseTransactions()) {
           $this->applyInverseEdgeTransactions(
             $object,
             $xaction,
             $edge_type->getInverseEdgeConstant());
         }
         continue;
       }
 
       $xtype = $this->getModularTransactionType($object, $type);
       if (!$xtype) {
         continue;
       }
 
       $xtype = clone $xtype;
       $xtype->setStorage($xaction);
       $xtype->didCommitTransaction($object, $xaction->getNewValue());
     }
   }
 
   public function setContentSource(PhabricatorContentSource $content_source) {
     $this->contentSource = $content_source;
     return $this;
   }
 
   public function setContentSourceFromRequest(AphrontRequest $request) {
     $this->setRequest($request);
     return $this->setContentSource(
       PhabricatorContentSource::newFromRequest($request));
   }
 
   public function getContentSource() {
     return $this->contentSource;
   }
 
   public function setRequest(AphrontRequest $request) {
     $this->request = $request;
     return $this;
   }
 
   public function getRequest() {
     return $this->request;
   }
 
   public function setCancelURI($cancel_uri) {
     $this->cancelURI = $cancel_uri;
     return $this;
   }
 
   public function getCancelURI() {
     return $this->cancelURI;
   }
 
   protected function getTransactionGroupID() {
     if ($this->transactionGroupID === null) {
       $this->transactionGroupID = Filesystem::readRandomCharacters(32);
     }
 
     return $this->transactionGroupID;
   }
 
   final public function applyTransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $is_new = ($object->getID() === null);
     $this->isNewObject = $is_new;
 
     $is_preview = $this->getIsPreview();
     $read_locking = false;
     $transaction_open = false;
 
     // If we're attempting to apply transactions, lock and reload the object
     // before we go anywhere. If we don't do this at the very beginning, we
     // may be looking at an older version of the object when we populate and
     // filter the transactions. See PHI1165 for an example.
 
     if (!$is_preview) {
       if (!$is_new) {
         $this->buildOldRecipientLists($object, $xactions);
 
         $object->openTransaction();
         $transaction_open = true;
 
         $object->beginReadLocking();
         $read_locking = true;
 
         $object->reload();
       }
     }
 
     try {
       $this->object = $object;
       $this->xactions = $xactions;
 
       $this->validateEditParameters($object, $xactions);
       $xactions = $this->newMFATransactions($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);
       }
 
       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[] = $this->validateTransactionsWithExtensions(
           $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);
         }
 
         if ($this->raiseWarnings) {
           $warnings = array();
           foreach ($xactions as $xaction) {
             if ($this->hasWarnings($object, $xaction)) {
               $warnings[] = $xaction;
             }
           }
           if ($warnings) {
             throw new PhabricatorApplicationTransactionWarningException(
               $warnings);
           }
         }
       }
 
       foreach ($xactions as $xaction) {
         $this->adjustTransactionValues($object, $xaction);
       }
 
       // Now that we've merged and combined transactions, check for required
       // capabilities. Note that we're doing this before filtering
       // transactions: if you try to apply an edit which you do not have
       // permission to apply, we want to give you a permissions error even
       // if the edit would have no effect.
       $this->applyCapabilityChecks($object, $xactions);
 
       $xactions = $this->filterTransactions($object, $xactions);
 
       if (!$is_preview) {
         $this->hasRequiredMFA = true;
         if ($this->getShouldRequireMFA()) {
           $this->requireMFA($object, $xactions);
         }
 
         if ($this->shouldApplyInitialEffects($object, $xactions)) {
           if (!$transaction_open) {
             $object->openTransaction();
             $transaction_open = true;
           }
         }
       }
 
       if ($this->shouldApplyInitialEffects($object, $xactions)) {
         $this->applyInitialEffects($object, $xactions);
       }
 
       // TODO: Once everything is on EditEngine, just use getIsNewObject() to
       // figure this out instead.
       $mark_as_create = false;
       $create_type = PhabricatorTransactions::TYPE_CREATE;
       foreach ($xactions as $xaction) {
         if ($xaction->getTransactionType() == $create_type) {
           $mark_as_create = true;
           break;
         }
       }
 
       if ($mark_as_create) {
         foreach ($xactions as $xaction) {
           $xaction->setIsCreateTransaction(true);
         }
       }
 
       $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())
         ->setIsNewComment(true);
 
       if (!$transaction_open) {
         $object->openTransaction();
         $transaction_open = true;
       }
 
       // We can technically test any object for CAN_INTERACT, but we can
       // run into some issues in doing so (for example, in project unit tests).
       // For now, only test for CAN_INTERACT if the object is explicitly a
       // lockable object.
 
       $was_locked = false;
       if ($object instanceof PhabricatorEditEngineLockableInterface) {
         $was_locked = !PhabricatorPolicyFilter::canInteract($actor, $object);
       }
 
       foreach ($xactions as $xaction) {
         $this->applyInternalEffects($object, $xaction);
       }
 
       $xactions = $this->didApplyInternalEffects($object, $xactions);
 
       try {
         $object->save();
       } catch (AphrontDuplicateKeyQueryException $ex) {
         // This callback has an opportunity to throw a better exception,
         // so execution may end here.
         $this->didCatchDuplicateKeyException($object, $xactions, $ex);
 
         throw $ex;
       }
 
       $group_id = $this->getTransactionGroupID();
 
       foreach ($xactions as $xaction) {
         if ($was_locked) {
           $is_override = $this->isLockOverrideTransaction($xaction);
           if ($is_override) {
             $xaction->setIsLockOverrideTransaction(true);
           }
         }
 
         $xaction->setObjectPHID($object->getPHID());
         $xaction->setTransactionGroupID($group_id);
 
         if ($xaction->getComment()) {
           $xaction->setPHID($xaction->generatePHID());
           $comment_editor->applyEdit($xaction, $xaction->getComment());
         } else {
 
           // TODO: This is a transitional hack to let us migrate edge
           // transactions to a more efficient storage format. For now, we're
           // going to write a new slim format to the database but keep the old
           // bulky format on the objects so we don't have to upgrade all the
           // edit logic to the new format yet. See T13051.
 
           $edge_type = PhabricatorTransactions::TYPE_EDGE;
           if ($xaction->getTransactionType() == $edge_type) {
             $bulky_old = $xaction->getOldValue();
             $bulky_new = $xaction->getNewValue();
 
             $record = PhabricatorEdgeChangeRecord::newFromTransaction($xaction);
             $slim_old = $record->getModernOldEdgeTransactionData();
             $slim_new = $record->getModernNewEdgeTransactionData();
 
             $xaction->setOldValue($slim_old);
             $xaction->setNewValue($slim_new);
             $xaction->save();
 
             $xaction->setOldValue($bulky_old);
             $xaction->setNewValue($bulky_new);
           } else {
             $xaction->save();
           }
         }
       }
 
       foreach ($xactions as $xaction) {
         $this->applyExternalEffects($object, $xaction);
       }
 
       $xactions = $this->applyFinalEffects($object, $xactions);
 
       if ($read_locking) {
         $object->endReadLocking();
         $read_locking = false;
       }
 
       if ($transaction_open) {
         $object->saveTransaction();
         $transaction_open = false;
       }
 
       $this->didCommitTransactions($object, $xactions);
 
     } catch (Exception $ex) {
       if ($read_locking) {
         $object->endReadLocking();
         $read_locking = false;
       }
 
       if ($transaction_open) {
         $object->killTransaction();
         $transaction_open = false;
       }
 
       throw $ex;
     }
 
     // If we need to perform cache engine updates, execute them now.
     id(new PhabricatorCacheEngine())
       ->updateObject($object);
 
     // 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()) {
       // Do not run Herald if we're just recording that this object was
       // mentioned elsewhere. This tends to create Herald side effects which
       // feel arbitrary, and can really slow down edits which mention a large
       // number of other objects. See T13114.
     } 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) {
           // Don't set a transcript ID if this is a transaction from another
           // application or source, like Owners.
           if ($herald_xaction->getAuthorPHID()) {
             continue;
           }
 
           $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(
           PhabricatorHeraldContentSource::SOURCECONST);
 
         $herald_editor = $this->newEditorCopy()
           ->setContinueOnNoEffect(true)
           ->setContinueOnMissingFields(true)
           ->setIsHeraldEditor(true)
           ->setActor($herald_actor)
           ->setActingAsPHID($herald_phid)
           ->setContentSource($herald_source);
 
         $herald_xactions = $herald_editor->applyTransactions(
           $object,
           $herald_xactions);
 
         // 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);
       }
 
       // If Herald did not generate transactions, we may still need to handle
       // "Send an Email" rules.
       $adapter = $this->getHeraldAdapter();
       $this->heraldEmailPHIDs = $adapter->getEmailPHIDs();
       $this->heraldForcedEmailPHIDs = $adapter->getForcedEmailPHIDs();
       $this->webhookMap = $adapter->getWebhookMap();
     }
 
     $xactions = $this->didApplyTransactions($object, $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 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;
 
     // See PHI1134. If we're a subeditor, we don't publish information about
     // the edit yet. Our parent editor still needs to finish applying
     // transactions and execute Herald, which may change the information we
     // publish.
 
     // For example, Herald actions may change the parent object's title or
     // visibility, or Herald may apply rules like "Must Encrypt" that affect
     // email.
 
     // Once the parent finishes work, it will queue its own publish step and
     // then queue publish steps for its children.
 
     $this->publishableObject = $object;
     $this->publishableTransactions = $xactions;
     if (!$this->parentEditor) {
       $this->queuePublishing();
     }
 
     return $xactions;
   }
 
   private function queuePublishing() {
     $object = $this->publishableObject;
     $xactions = $this->publishableTransactions;
 
     if (!$object) {
       throw new Exception(
         pht(
           'Editor method "queuePublishing()" was called, but no publishable '.
           'object is present. This Editor is not ready to publish.'));
     }
 
     // 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->getIsSilent()) {
       if ($this->shouldSendMail($object, $xactions)) {
         $this->mailShouldSend = true;
         $this->mailToPHIDs = $this->getMailTo($object);
         $this->mailCCPHIDs = $this->getMailCC($object);
         $this->mailUnexpandablePHIDs = $this->newMailUnexpandablePHIDs($object);
 
         // Add any recipients who were previously on the notification list
         // but were removed by this change.
         $this->applyOldRecipientLists();
 
         if ($object instanceof PhabricatorSubscribableInterface) {
           $this->mailMutedPHIDs = PhabricatorEdgeQuery::loadDestinationPHIDs(
             $object->getPHID(),
             PhabricatorMutedByEdgeType::EDGECONST);
         } else {
           $this->mailMutedPHIDs = array();
         }
 
         $mail_xactions = $this->getTransactionsForMail($object, $xactions);
         $stamps = $this->newMailStamps($object, $xactions);
         foreach ($stamps as $stamp) {
           $this->mailStamps[] = $stamp->toDictionary();
         }
       }
 
       if ($this->shouldPublishFeedStory($object, $xactions)) {
         $this->feedShouldPublish = true;
         $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,
       ));
 
     foreach ($this->subEditors as $sub_editor) {
       $sub_editor->queuePublishing();
     }
 
     $this->flushTransactionQueue($object);
   }
 
   protected function didCatchDuplicateKeyException(
     PhabricatorLiskDAO $object,
     array $xactions,
     Exception $ex) {
     return;
   }
 
   public function publishTransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $this->object = $object;
     $this->xactions = $xactions;
 
     // Hook for edges or other properties that may need (re-)loading
     $object = $this->willPublish($object, $xactions);
 
     // The object might have changed, so reassign it.
     $this->object = $object;
 
     $messages = array();
     if ($this->mailShouldSend) {
       $messages = $this->buildMail($object, $xactions);
     }
 
     if ($this->supportsSearch()) {
       PhabricatorSearchWorker::queueDocumentForIndexing(
         $object->getPHID(),
         array(
           'transactionPHIDs' => mpull($xactions, 'getPHID'),
         ));
     }
 
     if ($this->feedShouldPublish) {
       $mailed = array();
       foreach ($messages as $mail) {
         foreach ($mail->buildRecipientList() as $phid) {
           $mailed[$phid] = $phid;
         }
       }
 
       $this->publishFeedStory($object, $xactions, $mailed);
     }
 
     if ($this->sendHistory) {
       $history_mail = $this->buildHistoryMail($object);
       if ($history_mail) {
         $messages[] = $history_mail;
       }
     }
 
     foreach ($this->newAuxiliaryMail($object, $xactions) as $message) {
       $messages[] = $message;
     }
 
     // NOTE: This actually sends the mail. We do this last to reduce the chance
     // that we send some mail, hit an exception, then send the mail again when
     // retrying.
     foreach ($messages as $mail) {
       $mail->save();
     }
 
     $this->queueWebhooks($object, $xactions);
 
     return $xactions;
   }
 
   protected function didApplyTransactions($object, array $xactions) {
     // Hook for subclasses.
     return $xactions;
   }
 
   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->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();
 
       // See T13082. In the narrow case of applying inverse edge edits, we
       // expect the old value to be populated.
       if ($this->getIsInverseEdgeEditor()) {
         $expect_value = true;
       }
 
       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)));
       }
     }
   }
 
   private function applyCapabilityChecks(
     PhabricatorLiskDAO $object,
     array $xactions) {
     assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
 
     $can_edit = PhabricatorPolicyCapability::CAN_EDIT;
 
     if ($this->getIsNewObject()) {
       // If we're creating a new object, we don't need any special capabilities
       // on the object. The actor has already made it through creation checks,
       // and objects which haven't been created yet often can not be
       // meaningfully tested for capabilities anyway.
       $required_capabilities = array();
     } else {
       if (!$xactions && !$this->xactions) {
         // If we aren't doing anything, require CAN_EDIT to improve consistency.
         $required_capabilities = array($can_edit);
       } else {
         $required_capabilities = array();
 
         foreach ($xactions as $xaction) {
           $type = $xaction->getTransactionType();
 
           $xtype = $this->getModularTransactionType($object, $type);
           if (!$xtype) {
             $capabilities = $this->getLegacyRequiredCapabilities($xaction);
           } else {
             $capabilities = $xtype->getRequiredCapabilities($object, $xaction);
           }
 
           // For convenience, we allow flexibility in the return types because
           // it's very unusual that a transaction actually requires multiple
           // capability checks.
           if ($capabilities === null) {
             $capabilities = array();
           } else {
             $capabilities = (array)$capabilities;
           }
 
           foreach ($capabilities as $capability) {
             $required_capabilities[$capability] = $capability;
           }
         }
       }
     }
 
     $required_capabilities = array_fuse($required_capabilities);
     $actor = $this->getActor();
 
     if ($required_capabilities) {
       id(new PhabricatorPolicyFilter())
         ->setViewer($actor)
         ->requireCapabilities($required_capabilities)
         ->raisePolicyExceptions(true)
         ->apply(array($object));
     }
   }
 
   private function getLegacyRequiredCapabilities(
     PhabricatorApplicationTransaction $xaction) {
 
     $type = $xaction->getTransactionType();
     switch ($type) {
       case PhabricatorTransactions::TYPE_COMMENT:
         // TODO: Comments technically require CAN_INTERACT, but this is
         // currently somewhat special and handled through EditEngine. For now,
         // don't enforce it here.
         return null;
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         // Anyone can subscribe to or unsubscribe from anything they can view,
         // with no other permissions.
 
         $old = array_fuse($xaction->getOldValue());
         $new = array_fuse($xaction->getNewValue());
 
         // To remove users other than yourself, you must be able to edit the
         // object.
         $rem = array_diff_key($old, $new);
         foreach ($rem as $phid) {
           if ($phid !== $this->getActingAsPHID()) {
             return PhabricatorPolicyCapability::CAN_EDIT;
           }
         }
 
         // To add users other than yourself, you must be able to interact.
         // This allows "@mentioning" users to work as long as you can comment
         // on objects.
 
         // If you can edit, we return that policy instead so that you can
         // override a soft lock and still make edits.
 
         // TODO: This is a little bit hacky. We really want to be able to say
         // "this requires either interact or edit", but there's currently no
         // way to specify this kind of requirement.
 
         $can_edit = PhabricatorPolicyFilter::hasCapability(
           $this->getActor(),
           $this->object,
           PhabricatorPolicyCapability::CAN_EDIT);
 
         $add = array_diff_key($new, $old);
         foreach ($add as $phid) {
           if ($phid !== $this->getActingAsPHID()) {
             if ($can_edit) {
               return PhabricatorPolicyCapability::CAN_EDIT;
             } else {
               return PhabricatorPolicyCapability::CAN_INTERACT;
             }
           }
         }
 
         return null;
       case PhabricatorTransactions::TYPE_TOKEN:
         // TODO: This technically requires CAN_INTERACT, like comments.
         return null;
       case PhabricatorTransactions::TYPE_HISTORY:
         // This is a special magic transaction which sends you history via
         // email and is only partially supported in the upstream. You don't
         // need any capabilities to apply it.
         return null;
       case PhabricatorTransactions::TYPE_MFA:
         // Signing a transaction group with MFA does not require permissions
         // on its own.
         return null;
       case PhabricatorTransactions::TYPE_FILE:
         return null;
       case PhabricatorTransactions::TYPE_EDGE:
         return $this->getLegacyRequiredEdgeCapabilities($xaction);
       default:
         // For other older (non-modular) transactions, always require exactly
         // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional
         // capabilities must move to ModularTransactions.
         return PhabricatorPolicyCapability::CAN_EDIT;
     }
   }
 
   private function getLegacyRequiredEdgeCapabilities(
     PhabricatorApplicationTransaction $xaction) {
 
     // You don't need to have edit permission on an object to mention it or
     // otherwise add a relationship pointing toward it.
     if ($this->getIsInverseEdgeEditor()) {
       return null;
     }
 
     $edge_type = $xaction->getMetadataValue('edge:type');
     switch ($edge_type) {
       case PhabricatorMutedByEdgeType::EDGECONST:
         // At time of writing, you can only write this edge for yourself, so
         // you don't need permissions. If you can eventually mute an object
         // for other users, this would need to be revisited.
         return null;
       case PhabricatorProjectSilencedEdgeType::EDGECONST:
         // At time of writing, you can only write this edge for yourself, so
         // you don't need permissions. If you can eventually silence project
         // for other users, this would need to be revisited.
         return null;
       case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
         return null;
       case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST:
         $old = $xaction->getOldValue();
         $new = $xaction->getNewValue();
 
         $add = array_keys(array_diff_key($new, $old));
         $rem = array_keys(array_diff_key($old, $new));
 
         $actor_phid = $this->requireActor()->getPHID();
 
         $is_join = (($add === array($actor_phid)) && !$rem);
         $is_leave = (($rem === array($actor_phid)) && !$add);
 
         if ($is_join) {
           // You need CAN_JOIN to join a project.
           return PhabricatorPolicyCapability::CAN_JOIN;
         }
 
         if ($is_leave) {
           $object = $this->object;
           // You usually don't need any capabilities to leave a project...
           if ($object->getIsMembershipLocked()) {
             // ...you must be able to edit to leave locked projects, though.
             return PhabricatorPolicyCapability::CAN_EDIT;
           } else {
             return null;
           }
         }
 
         // You need CAN_EDIT to change members other than yourself.
         return PhabricatorPolicyCapability::CAN_EDIT;
       case PhabricatorObjectHasWatcherEdgeType::EDGECONST:
         // See PHI1024. Watching a project does not require CAN_EDIT.
         return null;
       default:
         return PhabricatorPolicyCapability::CAN_EDIT;
     }
   }
 
 
   private function buildSubscribeTransaction(
     PhabricatorLiskDAO $object,
     array $xactions,
     array $changes) {
 
     if (!($object instanceof PhabricatorSubscribableInterface)) {
       return null;
     }
 
     if ($this->shouldEnableMentions($object, $xactions)) {
       // Identify newly mentioned users. We ignore users who were previously
       // mentioned so that we don't re-subscribe users after an edit of text
       // which mentions them.
       $old_texts = mpull($changes, 'getOldValue');
       $new_texts = mpull($changes, 'getNewValue');
 
       $old_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
         $this->getActor(),
         $old_texts);
 
       $new_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
         $this->getActor(),
         $new_texts);
 
       $phids = array_diff($new_phids, $old_phids);
     } 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) {
         $user = idx($users, $phid);
 
         // Don't subscribe invalid users.
         if (!$user) {
           unset($phids[$key]);
           continue;
         }
 
         // Don't subscribe bots that get mentioned. If users truly intend
         // to subscribe them, they can add them explicitly, but it's generally
         // not useful to subscribe bots to objects.
         if ($user->getIsSystemAgent()) {
           unset($phids[$key]);
           continue;
         }
 
         // Do not subscribe mentioned users who do not have permission to see
         // the object.
         if ($object instanceof PhabricatorPolicyInterface) {
           $can_view = PhabricatorPolicyFilter::hasCapability(
             $user,
             $object,
             PhabricatorPolicyCapability::CAN_VIEW);
           if (!$can_view) {
             unset($phids[$key]);
             continue;
           }
         }
 
         // Don't subscribe users who are already automatically subscribed.
         if ($object->isAutomaticallySubscribed($phid)) {
           unset($phids[$key]);
           continue;
         }
       }
 
       $phids = array_values($phids);
     }
 
     if (!$phids) {
       return null;
     }
 
     $xaction = $object->getApplicationTransactionTemplate()
       ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
       ->setNewValue(array('+' => $phids));
 
     return $xaction;
   }
 
   protected function mergeTransactions(
     PhabricatorApplicationTransaction $u,
     PhabricatorApplicationTransaction $v) {
 
     $object = $this->object;
     $type = $u->getTransactionType();
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       return $xtype->mergeTransactions($object, $u, $v);
     }
 
     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.
    */
   protected 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);
 
     $changes = $this->getRemarkupChanges($xactions);
 
     $subscribe_xaction = $this->buildSubscribeTransaction(
       $object,
       $xactions,
       $changes);
     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,
       $changes,
       $engine);
 
     foreach ($block_xactions as $xaction) {
       $xactions[] = $xaction;
     }
 
     $file_xaction = $this->newFileTransaction(
       $object,
       $xactions,
       $changes);
     if ($file_xaction) {
       $xactions[] = $file_xaction;
     }
 
     return $xactions;
   }
 
 
   private function newFileTransaction(
     PhabricatorLiskDAO $object,
     array $xactions,
     array $remarkup_changes) {
 
     assert_instances_of(
       $remarkup_changes,
       'PhabricatorTransactionRemarkupChange');
 
     $new_map = array();
 
     $viewer = $this->getActor();
 
     $old_blocks = mpull($remarkup_changes, 'getOldValue');
     foreach ($old_blocks as $key => $old_block) {
       $old_blocks[$key] = phutil_string_cast($old_block);
     }
 
     $new_blocks = mpull($remarkup_changes, 'getNewValue');
     foreach ($new_blocks as $key => $new_block) {
       $new_blocks[$key] = phutil_string_cast($new_block);
     }
 
     $old_refs = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
       $viewer,
       $old_blocks);
     $old_refs = array_fuse($old_refs);
 
     $new_refs = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
       $viewer,
       $new_blocks);
     $new_refs = array_fuse($new_refs);
 
     $add_refs = array_diff_key($new_refs, $old_refs);
     foreach ($add_refs as $file_phid) {
       $new_map[$file_phid] = PhabricatorFileAttachment::MODE_REFERENCE;
     }
 
     foreach ($remarkup_changes as $remarkup_change) {
       $metadata = $remarkup_change->getMetadata();
 
       $attached_phids = idx($metadata, 'attachedFilePHIDs', array());
       foreach ($attached_phids as $file_phid) {
 
         // If the blocks don't include a new embedded reference to this file,
         // do not actually attach it. A common way for this to happen is for
         // a user to upload a file, then change their mind and remove the
         // reference. We do not want to attach the file if they decided against
         // referencing it.
 
         if (!isset($new_map[$file_phid])) {
           continue;
         }
 
         $new_map[$file_phid] = PhabricatorFileAttachment::MODE_ATTACH;
       }
     }
 
     $file_phids = $this->extractFilePHIDs($object, $xactions);
     foreach ($file_phids as $file_phid) {
       $new_map[$file_phid] = PhabricatorFileAttachment::MODE_ATTACH;
     }
 
     if (!$new_map) {
       return null;
     }
 
     $xaction = $object->getApplicationTransactionTemplate()
       ->setIgnoreOnNoEffect(true)
       ->setTransactionType(PhabricatorTransactions::TYPE_FILE)
       ->setMetadataValue('attach.implicit', true)
       ->setNewValue($new_map);
 
     return $xaction;
   }
 
 
   private function getRemarkupChanges(array $xactions) {
     $changes = array();
 
     foreach ($xactions as $key => $xaction) {
       foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
         $changes[] = $change;
       }
     }
 
     return $changes;
   }
 
   private function getRemarkupChangesFromTransaction(
     PhabricatorApplicationTransaction $transaction) {
     return $transaction->getRemarkupChanges();
   }
 
   private function expandRemarkupBlockTransactions(
     PhabricatorLiskDAO $object,
     array $xactions,
     array $changes,
     PhutilMarkupEngine $engine) {
 
     $block_xactions = $this->expandCustomRemarkupBlockTransactions(
       $object,
       $xactions,
       $changes,
       $engine);
 
     $mentioned_phids = array();
     if ($this->shouldEnableMentions($object, $xactions)) {
       foreach ($changes as $change) {
         // Here, we don't care about processing only new mentions after an edit
         // because there is no way for an object to ever "unmention" itself on
         // another object, so we can ignore the old value.
         $engine->markupText($change->getNewValue());
 
         $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();
 
     $unmentionable_map = $this->getUnmentionablePHIDMap();
 
     $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 (isset($unmentionable_map[$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,
     array $changes,
     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) {
           $other_xaction = $result[$other_key];
 
           // Don't merge transactions with different authors. For example,
           // don't merge Herald transactions and owners transactions.
           if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
             continue;
           }
 
           $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);
   }
 
   public 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());
     }
 
     return $this->getPHIDList($old, $xaction->getNewValue());
   }
 
   public function getPHIDList(array $old, array $new) {
     $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), '%s' (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, $xaction->getMetadataValue('edge:type'));
     }
 
     $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, $edge_type) {
     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" on transaction of type "%s").',
             $key,
             $edge_type));
       }
       if (!is_array($item) && $item !== $key) {
         throw new Exception(
           pht(
             'Edge transactions must have PHIDs or edge specs as values '.
             '(found value "%s" on transaction of type "%s").',
             $item,
             $edge_type));
       }
     }
   }
 
   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;
     $type_mfa = PhabricatorTransactions::TYPE_MFA;
 
     $no_effect = array();
     $has_comment = false;
     $any_effect = false;
 
     $meta_xactions = array();
     foreach ($xactions as $key => $xaction) {
       if ($xaction->getTransactionType() === $type_mfa) {
         $meta_xactions[$key] = $xaction;
         continue;
       }
 
       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 every transaction is a meta-transaction applying to the transaction
     // group, these transactions are junk.
     if (count($meta_xactions) == count($xactions)) {
       $no_effect = $xactions;
       $any_effect = false;
     }
 
     if (!$no_effect) {
       return $xactions;
     }
 
     // If none of the transactions have an effect, the meta-transactions also
     // have no effect. Add them to the "no effect" list so we get a full set
     // of errors for everything.
     if (!$any_effect && !$has_comment) {
       $no_effect += $meta_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->hasComment()) {
         $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.
+   * @param PhabricatorLiskDAO $object Object being edited.
+   * @param string $type Transaction type to validate.
+   * @param list<PhabricatorApplicationTransaction> $xactions 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();
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       $errors[] = $xtype->validateTransactions($object, $xactions);
     }
 
     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_SPACE:
         $errors[] = $this->validateSpaceTransactions(
           $object,
           $xactions,
           $type);
         break;
       case PhabricatorTransactions::TYPE_SUBTYPE:
         $errors[] = $this->validateSubtypeTransactions(
           $object,
           $xactions,
           $type);
         break;
       case PhabricatorTransactions::TYPE_MFA:
         $errors[] = $this->validateMFATransactions(
           $object,
           $xactions,
           $type);
         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;
       case PhabricatorTransactions::TYPE_FILE:
         $errors[] = $this->validateFileTransactions(
           $object,
           $xactions,
           $type);
         break;
     }
 
     return array_mergev($errors);
   }
 
   private function validateFileTransactions(
     PhabricatorLiskDAO $object,
     array $xactions,
     $transaction_type) {
 
     $errors = array();
 
     $mode_map = PhabricatorFileAttachment::getModeList();
     $mode_map = array_fuse($mode_map);
 
     $file_phids = array();
     foreach ($xactions as $xaction) {
       $new = $xaction->getNewValue();
 
       if (!is_array($new)) {
         $errors[] = new PhabricatorApplicationTransactionValidationError(
           $transaction_type,
           pht('Invalid'),
           pht(
             'File attachment transaction must have a map of files to '.
             'attachment modes, found "%s".',
             phutil_describe_type($new)),
           $xaction);
         continue;
       }
 
       foreach ($new as $file_phid => $attachment_mode) {
         $file_phids[$file_phid] = $file_phid;
 
         if (is_string($attachment_mode) && isset($mode_map[$attachment_mode])) {
           continue;
         }
 
         if (!is_string($attachment_mode)) {
           $errors[] = new PhabricatorApplicationTransactionValidationError(
             $transaction_type,
             pht('Invalid'),
             pht(
               'File attachment mode (for file "%s") is invalid. Expected '.
               'a string, found "%s".',
               $file_phid,
               phutil_describe_type($attachment_mode)),
             $xaction);
         } else {
           $errors[] = new PhabricatorApplicationTransactionValidationError(
             $transaction_type,
             pht('Invalid'),
             pht(
               'File attachment mode "%s" (for file "%s") is invalid. Valid '.
               'modes are: %s.',
               $attachment_mode,
               $file_phid,
               pht_list($mode_map)),
             $xaction);
         }
       }
     }
 
     if ($file_phids) {
       $file_map = id(new PhabricatorFileQuery())
         ->setViewer($this->getActor())
         ->withPHIDs($file_phids)
         ->execute();
       $file_map = mpull($file_map, null, 'getPHID');
     } else {
       $file_map = array();
     }
 
     foreach ($xactions as $xaction) {
       $new = $xaction->getNewValue();
 
       if (!is_array($new)) {
         continue;
       }
 
       foreach ($new as $file_phid => $attachment_mode) {
         if (isset($file_map[$file_phid])) {
           continue;
         }
 
         $errors[] = new PhabricatorApplicationTransactionValidationError(
           $transaction_type,
           pht('Invalid'),
           pht(
             'File "%s" is invalid: it could not be loaded, or you do not '.
             'have permission to view it. You must be able to see a file to '.
             'attach it to an object.',
             $file_phid),
           $xaction);
       }
     }
 
     return $errors;
   }
 
 
   public 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;
   }
 
 
   private function validateSpaceTransactions(
     PhabricatorLiskDAO $object,
     array $xactions,
     $transaction_type) {
     $errors = array();
 
     $actor = $this->getActor();
 
     $has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor);
     $actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor);
     $active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces(
       $actor);
     foreach ($xactions as $xaction) {
       $space_phid = $xaction->getNewValue();
 
       if ($space_phid === null) {
         if (!$has_spaces) {
           // The install doesn't have any spaces, so this is fine.
           continue;
         }
 
         // The install has some spaces, so every object needs to be put
         // in a valid space.
         $errors[] = new PhabricatorApplicationTransactionValidationError(
           $transaction_type,
           pht('Invalid'),
           pht('You must choose a space for this object.'),
           $xaction);
         continue;
       }
 
       // If the PHID isn't `null`, it needs to be a valid space that the
       // viewer can see.
       if (empty($actor_spaces[$space_phid])) {
         $errors[] = new PhabricatorApplicationTransactionValidationError(
           $transaction_type,
           pht('Invalid'),
           pht(
             'You can not shift this object in the selected space, because '.
             'the space does not exist or you do not have access to it.'),
           $xaction);
       } else if (empty($active_spaces[$space_phid])) {
 
         // It's OK to edit objects in an archived space, so just move on if
         // we aren't adjusting the value.
         $old_space_phid = $this->getTransactionOldValue($object, $xaction);
         if ($space_phid == $old_space_phid) {
           continue;
         }
 
         $errors[] = new PhabricatorApplicationTransactionValidationError(
           $transaction_type,
           pht('Archived'),
           pht(
             'You can not shift this object into the selected space, because '.
             'the space is archived. Objects can not be created inside (or '.
             'moved into) archived spaces.'),
           $xaction);
       }
     }
 
     return $errors;
   }
 
   private function validateSubtypeTransactions(
     PhabricatorLiskDAO $object,
     array $xactions,
     $transaction_type) {
     $errors = array();
 
     $map = $object->newEditEngineSubtypeMap();
     $old = $object->getEditEngineSubtype();
     foreach ($xactions as $xaction) {
       $new = $xaction->getNewValue();
 
       if ($old == $new) {
         continue;
       }
 
       if (!$map->isValidSubtype($new)) {
         $errors[] = new PhabricatorApplicationTransactionValidationError(
           $transaction_type,
           pht('Invalid'),
           pht(
             'The subtype "%s" is not a valid subtype.',
             $new),
           $xaction);
         continue;
       }
     }
 
     return $errors;
   }
 
   private function validateMFATransactions(
     PhabricatorLiskDAO $object,
     array $xactions,
     $transaction_type) {
     $errors = array();
 
     $factors = id(new PhabricatorAuthFactorConfigQuery())
       ->setViewer($this->getActor())
       ->withUserPHIDs(array($this->getActingAsPHID()))
       ->withFactorProviderStatuses(
         array(
           PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
           PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
         ))
       ->execute();
 
     foreach ($xactions as $xaction) {
       if (!$factors) {
         $errors[] = new PhabricatorApplicationTransactionValidationError(
           $transaction_type,
           pht('No MFA'),
           pht(
             'You do not have any MFA factors attached to your account, so '.
             'you can not sign this transaction group with MFA. Add MFA to '.
             'your account in Settings.'),
           $xaction);
       }
     }
 
     if ($xactions) {
       $this->setShouldRequireMFA(true);
     }
 
     return $errors;
   }
 
   protected function adjustObjectForPolicyChecks(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $copy = clone $object;
 
     foreach ($xactions as $xaction) {
       switch ($xaction->getTransactionType()) {
         case PhabricatorTransactions::TYPE_SUBSCRIBERS:
           $clone_xaction = clone $xaction;
           $clone_xaction->setOldValue(array_values($this->subscribers));
           $clone_xaction->setNewValue(
             $this->getPHIDTransactionNewValue(
               $clone_xaction));
 
           PhabricatorPolicyRule::passTransactionHintToRule(
             $copy,
             new PhabricatorSubscriptionsSubscribersPolicyRule(),
             array_fuse($clone_xaction->getNewValue()));
 
           break;
         case PhabricatorTransactions::TYPE_SPACE:
           $space_phid = $this->getTransactionNewValue($object, $xaction);
           $copy->setSpacePHID($space_phid);
           break;
       }
     }
 
     return $copy;
   }
 
   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.
+   * @param wild $field_value Current field value.
+   * @param list<PhabricatorApplicationTransaction> $xactions 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 (($field_value !== null && strlen($field_value)) && empty($xactions)) {
       return false;
     }
 
     if ($xactions && strlen(last($xactions)->getNewValue())) {
       return false;
     }
 
     return true;
   }
 
 
 /* -(  Implicit CCs  )------------------------------------------------------- */
 
 
   /**
    * Adds the actor as a subscriber to the object with which they interact
    * @param PhabricatorLiskDAO $object on which the action is performed
    * @param array $xactions Transactions to apply
    * @return array Transactions to apply
    */
   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;
       }
     }
 
     $actor = $this->getActor();
 
     $user = id(new PhabricatorPeopleQuery())
       ->setViewer($actor)
       ->withPHIDs(array($actor_phid))
       ->executeOne();
     if (!$user) {
       return $xactions;
     }
 
     // When a bot acts (usually via the API), don't automatically subscribe
     // them as a side effect. They can always subscribe explicitly if they
     // want, and bot subscriptions normally just clutter things up since bots
     // usually do not read email.
     if ($user->getIsSystemAgent()) {
       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;
   }
 
   /**
    * Whether the action implies the actor should be subscribed on the object
    * @param PhabricatorLiskDAO $object on which the action is performed
    * @param PhabricatorApplicationTransaction $xaction Transaction to apply
    * @return bool True if the actor should be subscribed on the object
    */
   protected function shouldImplyCC(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     return ($xaction->isCommentTransaction() &&
       !($xaction->getComment()->getIsRemoved()));
   }
 
 
 /* -(  Sending Mail  )------------------------------------------------------- */
 
 
   /**
    * @task mail
    */
   protected function shouldSendMail(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return false;
   }
 
 
   /**
    * @task mail
    */
   private function buildMail(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $email_to = $this->mailToPHIDs;
     $email_cc = $this->mailCCPHIDs;
     $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs);
 
     $unexpandable = $this->mailUnexpandablePHIDs;
     if (!is_array($unexpandable)) {
       $unexpandable = array();
     }
 
     $messages = $this->buildMailWithRecipients(
       $object,
       $xactions,
       $email_to,
       $email_cc,
       $unexpandable);
 
     $this->runHeraldMailRules($messages);
 
     return $messages;
   }
 
   private function buildMailWithRecipients(
     PhabricatorLiskDAO $object,
     array $xactions,
     array $email_to,
     array $email_cc,
     array $unexpandable) {
 
     $targets = $this->buildReplyHandler($object)
       ->setUnexpandablePHIDs($unexpandable)
       ->getMailTargets($email_to, $email_cc);
 
     // Set this explicitly before we start swapping out the effective actor.
     $this->setActingAsPHID($this->getActingAsPHID());
 
     $xaction_phids = mpull($xactions, 'getPHID');
 
     $messages = 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 the transactions for the current viewer.
         if ($xaction_phids) {
           $query = PhabricatorApplicationTransactionQuery::newQueryForObject(
             $object);
 
           $mail_xactions = $query
             ->setViewer($viewer)
             ->withObjectPHIDs(array($object->getPHID()))
             ->withPHIDs($xaction_phids)
             ->execute();
 
           // Sort the mail transactions in the input order.
           $mail_xactions = mpull($mail_xactions, null, 'getPHID');
           $mail_xactions = array_select_keys($mail_xactions, $xaction_phids);
           $mail_xactions = array_values($mail_xactions);
         } else {
           $mail_xactions = array();
         }
 
         // Reload handles for the current viewer. This covers older code which
         // emits a list of handle PHIDs upfront.
         $this->loadHandles($mail_xactions);
 
         $mail = $this->buildMailForTarget($object, $mail_xactions, $target);
 
         if ($mail) {
           if ($this->mustEncrypt) {
             $mail
               ->setMustEncrypt(true)
               ->setMustEncryptReasons($this->mustEncrypt);
           }
         }
       } catch (Exception $ex) {
         $caught = $ex;
       }
 
       $this->setActor($original_actor);
       unset($locale);
 
       if ($caught) {
         throw $ex;
       }
 
       if ($mail) {
         $messages[] = $mail;
       }
     }
 
     return $messages;
   }
 
   protected function getTransactionsForMail(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return $xactions;
   }
 
   private function buildMailForTarget(
     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_xactions = $this->getTransactionsForMail($object, $xactions);
 
     $mail = $this->buildMailTemplate($object);
     $body = $this->buildMailBody($object, $mail_xactions);
 
     $mail_tags = $this->getMailTags($object, $mail_xactions);
     $action = $this->getMailAction($object, $mail_xactions);
     $stamps = $this->generateMailStamps($object, $this->mailStamps);
 
     if (PhabricatorEnv::getEnvConfig('metamta.email-preferences')) {
       $this->addEmailPreferenceSectionToMailBody(
         $body,
         $object,
         $mail_xactions);
     }
 
     $muted_phids = $this->mailMutedPHIDs;
     if (!is_array($muted_phids)) {
       $muted_phids = array();
     }
 
     $mail
       ->setSensitiveContent(false)
       ->setFrom($this->getActingAsPHID())
       ->setSubjectPrefix($this->getMailSubjectPrefix())
       ->setVarySubjectPrefix('['.$action.']')
       ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
       ->setRelatedPHID($object->getPHID())
       ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
       ->setMutedPHIDs($muted_phids)
       ->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());
     }
 
     // If we have stamps, attach the raw dictionary version (not the actual
     // objects) to the mail so that debugging tools can see what we used to
     // render the final list.
     if ($this->mailStamps) {
       $mail->setMailStampMetadata($this->mailStamps);
     }
 
     // If we have rendered stamps, attach them to the mail.
     if ($stamps) {
       $mail->setMailStamps($stamps);
     }
 
     return $target->willSendMail($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 head(msortv($xactions, 'newActionStrengthSortVector'));
   }
 
 
   /**
    * @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.'));
   }
 
 
   protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) {
     return array();
   }
 
 
   /**
    * @task mail
    */
   protected function getMailCC(PhabricatorLiskDAO $object) {
     $phids = array();
     $has_support = false;
 
     if ($object instanceof PhabricatorSubscribableInterface) {
       $phid = $object->getPHID();
       $phids[] = PhabricatorSubscribersQuery::loadSubscribersForPHID($phid);
       $has_support = true;
     }
 
     if ($object instanceof PhabricatorProjectInterface) {
       $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
         $object->getPHID(),
         PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
 
       if ($project_phids) {
         $projects = id(new PhabricatorProjectQuery())
           ->setViewer(PhabricatorUser::getOmnipotentUser())
           ->withPHIDs($project_phids)
           ->needWatchers(true)
           ->execute();
 
         $watcher_phids = array();
         foreach ($projects as $project) {
           foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
             $watcher_phids[$phid] = $phid;
           }
         }
 
         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('The object being edited does not implement any standard '.
           'interfaces (like PhabricatorSubscribableInterface) which allow '.
           'CCs to be generated automatically. Override the "getMailCC()" '.
           'method and generate CCs explicitly.'));
     }
 
     return array_mergev($phids);
   }
 
 
   /**
    * @task mail
    */
   protected function buildMailBody(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $body = id(new PhabricatorMetaMTAMailBody())
       ->setViewer($this->requireActor())
       ->setContextObject($object);
 
     $button_label = $this->getObjectLinkButtonLabelForMail($object);
     $button_uri = $this->getObjectLinkButtonURIForMail($object);
 
     $this->addHeadersAndCommentsToMailBody(
       $body,
       $xactions,
       $button_label,
       $button_uri);
 
     $this->addCustomFieldsToMailBody($body, $object, $xactions);
 
     return $body;
   }
 
   protected function getObjectLinkButtonLabelForMail(
     PhabricatorLiskDAO $object) {
     return null;
   }
 
   protected function getObjectLinkButtonURIForMail(
     PhabricatorLiskDAO $object) {
 
     // Most objects define a "getURI()" method which does what we want, but
     // this isn't formally part of an interface at time of writing. Try to
     // call the method, expecting an exception if it does not exist.
 
     try {
       $uri = $object->getURI();
       return PhabricatorEnv::getProductionURI($uri);
     } catch (Exception $ex) {
       return null;
     }
   }
 
   /**
    * @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,
     $object_label = null,
     $object_uri = null) {
 
     // First, remove transactions which shouldn't be rendered in mail.
     foreach ($xactions as $key => $xaction) {
       if ($xaction->shouldHideForMail($xactions)) {
         unset($xactions[$key]);
       }
     }
 
     $headers = array();
     $headers_html = array();
     $comments = array();
     $details = array();
 
     $seen_comment = false;
     foreach ($xactions as $xaction) {
 
       // Most mail has zero or one comments. In these cases, we render the
       // "alice added a comment." transaction in the header, like a normal
       // transaction.
 
       // Some mail, like Differential undraft mail or "!history" mail, may
       // have two or more comments. In these cases, we'll put the first
       // "alice added a comment." transaction in the header normally, but
       // move the other transactions down so they provide context above the
       // actual comment.
 
       $comment = $this->getBodyForTextMail($xaction);
       if ($comment !== null) {
         $is_comment = true;
         $comments[] = array(
           'xaction' => $xaction,
           'comment' => $comment,
           'initial' => !$seen_comment,
         );
       } else {
         $is_comment = false;
       }
 
       if (!$is_comment || !$seen_comment) {
         $header = $this->getTitleForTextMail($xaction);
         if ($header !== null) {
           $headers[] = $header;
         }
 
         $header_html = $this->getTitleForHTMLMail($xaction);
         if ($header_html !== null) {
           $headers_html[] = $header_html;
         }
       }
 
       if ($xaction->hasChangeDetailsForMail()) {
         $details[] = $xaction;
       }
 
       if ($is_comment) {
         $seen_comment = true;
       }
     }
 
     $headers_text = implode("\n", $headers);
     $body->addRawPlaintextSection($headers_text);
 
     $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
 
     $header_button = null;
     if ($object_label !== null && $object_uri !== null) {
       $button_style = array(
         'text-decoration: none;',
         'padding: 4px 8px;',
         'margin: 0 8px 8px;',
         'float: right;',
         'color: #464C5C;',
         'font-weight: bold;',
         'border-radius: 3px;',
         'background-color: #F7F7F9;',
         'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
         'display: inline-block;',
         'border: 1px solid rgba(71,87,120,.2);',
       );
 
       $header_button = phutil_tag(
         'a',
         array(
           'style' => implode(' ', $button_style),
           'href' => $object_uri,
         ),
         $object_label);
     }
 
     $xactions_style = array();
 
     $header_action = phutil_tag(
       'td',
       array(),
       $header_button);
 
     $header_action = phutil_tag(
       'td',
       array(
         'style' => implode(' ', $xactions_style),
       ),
       array(
         $headers_html,
         // Add an extra newline to prevent the "View Object" button from
         // running into the transaction text in Mail.app text snippet
         // previews.
         "\n",
       ));
 
     $headers_html = phutil_tag(
       'table',
       array(),
       phutil_tag('tr', array(), array($header_action, $header_button)));
 
     $body->addRawHTMLSection($headers_html);
 
     foreach ($comments as $spec) {
       $xaction = $spec['xaction'];
       $comment = $spec['comment'];
       $is_initial = $spec['initial'];
 
       // If this is not the first comment in the mail, add the header showing
       // who wrote the comment immediately above the comment.
       if (!$is_initial) {
         $header = $this->getTitleForTextMail($xaction);
         if ($header !== null) {
           $body->addRawPlaintextSection($header);
         }
 
         $header_html = $this->getTitleForHTMLMail($xaction);
         if ($header_html !== null) {
           $body->addRawHTMLSection($header_html);
         }
       }
 
       $body->addRemarkupSection(null, $comment);
     }
 
     foreach ($details as $xaction) {
       $details = $xaction->renderChangeDetailsForMail($body->getViewer());
       if ($details !== null) {
         $label = $this->getMailDiffSectionHeader($xaction);
         $body->addHTMLSection($label, $details);
       }
     }
 
   }
 
   private function getMailDiffSectionHeader($xaction) {
     $type = $xaction->getTransactionType();
     $object = $this->object;
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       return $xtype->getMailDiffSectionHeader();
     }
 
     return pht('EDIT DETAILS');
   }
 
   /**
    * @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);
       }
     }
   }
 
 
   /**
    * @task mail
    */
   private function runHeraldMailRules(array $messages) {
     foreach ($messages as $message) {
       $engine = new HeraldEngine();
       $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
         ->setObject($message);
 
       $rules = $engine->loadRulesForAdapter($adapter);
       $effects = $engine->applyRules($rules, $adapter);
       $engine->applyEffects($effects, $adapter, $rules);
     }
   }
 
 
 /* -(  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) {
 
     // If some transactions are forcing notification delivery, add the forced
     // recipients to the notify list.
     $force_list = array();
     foreach ($xactions as $xaction) {
       $force_phids = $xaction->getForceNotifyPHIDs();
 
       if (!$force_phids) {
         continue;
       }
 
       foreach ($force_phids as $force_phid) {
         $force_list[] = $force_phid;
       }
     }
 
     $to_list = $this->getMailTo($object);
     $cc_list = $this->getMailCC($object);
 
     $full_list = array_merge($force_list, $to_list, $cc_list);
     $full_list = array_fuse($full_list);
 
     return array_keys($full_list);
   }
 
 
   /**
    * @task feed
    */
   protected function getFeedStoryData(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $xactions = msortv($xactions, 'newActionStrengthSortVector');
 
     return array(
       'objectPHID'        => $object->getPHID(),
       'transactionPHIDs'  => mpull($xactions, 'getPHID'),
     );
   }
 
 
   /**
    * @task feed
    */
   protected function publishFeedStory(
     PhabricatorLiskDAO $object,
     array $xactions,
     array $mailed_phids) {
 
     // Remove transactions which don't publish feed stories or notifications.
     // These never show up anywhere, so we don't need to do anything with them.
     foreach ($xactions as $key => $xaction) {
       if (!$xaction->shouldHideForFeed()) {
         continue;
       }
 
       if (!$xaction->shouldHideForNotifications()) {
         continue;
       }
 
       unset($xactions[$key]);
     }
 
     if (!$xactions) {
       return;
     }
 
     $related_phids = $this->feedRelatedPHIDs;
     $subscribed_phids = $this->feedNotifyPHIDs;
 
     // Remove muted users from the subscription list so they don't get
     // notifications, either.
     $muted_phids = $this->mailMutedPHIDs;
     if (!is_array($muted_phids)) {
       $muted_phids = array();
     }
     $subscribed_phids = array_fuse($subscribed_phids);
     foreach ($muted_phids as $muted_phid) {
       unset($subscribed_phids[$muted_phid]);
     }
     $subscribed_phids = array_values($subscribed_phids);
 
     $story_type = $this->getFeedStoryType();
     $story_data = $this->getFeedStoryData($object, $xactions);
 
     $unexpandable_phids = $this->mailUnexpandablePHIDs;
     if (!is_array($unexpandable_phids)) {
       $unexpandable_phids = array();
     }
 
     id(new PhabricatorFeedStoryPublisher())
       ->setStoryType($story_type)
       ->setStoryData($story_data)
       ->setStoryTime(time())
       ->setStoryAuthorPHID($this->getActingAsPHID())
       ->setRelatedPHIDs($related_phids)
       ->setPrimaryObjectPHID($object->getPHID())
       ->setSubscribedPHIDs($subscribed_phids)
       ->setUnexpandablePHIDs($unexpandable_phids)
       ->setMailRecipientPHIDs($mailed_phids)
       ->setMailTags($this->getMailTags($object, $xactions))
       ->publish();
   }
 
 
 /* -(  Search Index  )------------------------------------------------------- */
 
 
   /**
    * @task search
    */
   protected function supportsSearch() {
     return false;
   }
 
 
 /* -(  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)
       ->setContentSource($this->getContentSource())
       ->setIsNewObject($this->getIsNewObject())
       ->setActingAsPHID($this->getActingAsPHID())
       ->setAppliedTransactions($xactions);
 
     if ($this->getApplicationEmail()) {
       $adapter->setApplicationEmail($this->getApplicationEmail());
     }
 
     // If this editor is operating in silent mode, tell Herald that we aren't
     // going to send any mail. This allows it to skip "the first time this
     // rule matches, send me an email" rules which would otherwise match even
     // though we aren't going to send any mail.
     if ($this->getIsSilent()) {
       $adapter->setForbiddenAction(
         HeraldMailableState::STATECONST,
         HeraldCoreStateReasons::REASON_SILENT);
     }
 
     $xscript = HeraldEngine::loadAndApplyRules($adapter);
 
     $this->setHeraldAdapter($adapter);
     $this->setHeraldTranscript($xscript);
 
     if ($adapter instanceof HarbormasterBuildableAdapterInterface) {
       $buildable_phid = $adapter->getHarbormasterBuildablePHID();
 
       HarbormasterBuildable::applyBuildPlans(
         $buildable_phid,
         $adapter->getHarbormasterContainerPHID(),
         $adapter->getQueuedHarbormasterBuildRequests());
 
       // Whether we queued any builds or not, any automatic buildable for this
       // object is now done preparing builds and can transition into a
       // completed status.
       $buildables = id(new HarbormasterBuildableQuery())
         ->setViewer(PhabricatorUser::getOmnipotentUser())
         ->withManualBuildables(false)
         ->withBuildablePHIDs(array($buildable_phid))
         ->execute();
       foreach ($buildables as $buildable) {
         // If this buildable has already moved beyond preparation, we don't
         // need to nudge it again.
         if (!$buildable->isPreparing()) {
           continue;
         }
         $buildable->sendMessage(
           $this->getActor(),
           HarbormasterMessageType::BUILDABLE_BUILD,
           true);
       }
     }
 
     $this->mustEncrypt = $adapter->getMustEncryptReasons();
 
     // See PHI1134. Propagate "Must Encrypt" state to sub-editors.
     foreach ($this->subEditors as $sub_editor) {
       $sub_editor->mustEncrypt = $this->mustEncrypt;
     }
 
     $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript);
     assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction');
 
     $queue_xactions = $adapter->getQueuedTransactions();
 
     return array_merge(
       array_values($apply_xactions),
       array_values($queue_xactions));
   }
 
   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) {
 
     $phids = array();
 
     foreach ($xactions as $xaction) {
       $type = $xaction->getTransactionType();
 
       $xtype = $this->getModularTransactionType($object, $type);
       if ($xtype) {
         $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
       } else {
         $phids[] = $this->extractFilePHIDsFromCustomTransaction(
           $object,
           $xaction);
       }
     }
 
     $phids = array_unique(array_filter(array_mergev($phids)));
 
     return $phids;
   }
 
   /**
    * @task files
    */
   protected function extractFilePHIDsFromCustomTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     return array();
   }
 
 
   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();
 
     $object_phid = $object->getPHID();
 
     foreach ($nodes as $node) {
       if (!($node instanceof PhabricatorApplicationTransactionInterface)) {
         continue;
       }
 
       if ($node instanceof PhabricatorUser) {
         // TODO: At least for now, don't record inverse edge transactions
         // for users (for example, "alincoln joined project X"): Feed fills
         // this role instead.
         continue;
       }
 
       $node_phid = $node->getPHID();
       $editor = $node->getApplicationTransactionEditor();
       $template = $node->getApplicationTransactionTemplate();
 
       // See T13082. We have to build these transactions with synthetic values
       // because we've already applied the actual edit to the edge database
       // table. If we try to apply this transaction naturally, it will no-op
       // itself because it doesn't have any effect.
 
       $edge_query = id(new PhabricatorEdgeQuery())
         ->withSourcePHIDs(array($node_phid))
         ->withEdgeTypes(array($inverse_type));
 
       $edge_query->execute();
 
       $edge_phids = $edge_query->getDestinationPHIDs();
       $edge_phids = array_fuse($edge_phids);
 
       $new_phids = $edge_phids;
       $old_phids = $edge_phids;
 
       if (isset($add[$node_phid])) {
         unset($old_phids[$object_phid]);
       } else {
         $old_phids[$object_phid] = $object_phid;
       }
 
       $template
         ->setTransactionType($xaction->getTransactionType())
         ->setMetadataValue('edge:type', $inverse_type)
         ->setOldValue($old_phids)
         ->setNewValue($new_phids);
 
       $editor = $this->newSubEditor($editor)
         ->setContinueOnNoEffect(true)
         ->setContinueOnMissingFields(true)
         ->setIsInverseEdgeEditor(true);
 
       $editor->applyTransactions($node, 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
    */
   private function getWorkerState() {
     $state = array();
     foreach ($this->getAutomaticStateProperties() as $property) {
       $state[$property] = $this->$property;
     }
 
     $custom_state = $this->getCustomWorkerState();
     $custom_encoding = $this->getCustomWorkerStateEncoding();
 
     $state += array(
       'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
       'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
       'custom.encoding' => $custom_encoding,
     );
 
     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();
   }
 
 
   /**
    * Hook; return storage encoding for custom properties which need to be
    * passed to workers.
    *
    * This primarily allows binary data to be passed to workers and survive
    * JSON encoding.
    *
    * @return dict<string, string> Property encodings.
    * @task workers
    */
   protected function getCustomWorkerStateEncoding() {
     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}.
+   * @param dict<string, wild> $state 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_state = idx($state, 'custom', array());
     $custom_encodings = idx($state, 'custom.encoding', array());
     $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
 
     $this->loadCustomWorkerState($custom);
 
     return $this;
   }
 
 
   /**
    * Hook; set custom properties on the editor from data emitted by
    * @{method:getCustomWorkerState}.
    *
-   * @param dict<string, wild> Custom state,
+   * @param dict<string, wild> $state 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',
       'isNewObject',
       'heraldEmailPHIDs',
       'heraldForcedEmailPHIDs',
       'heraldHeader',
       'mailToPHIDs',
       'mailCCPHIDs',
       'feedNotifyPHIDs',
       'feedRelatedPHIDs',
       'feedShouldPublish',
       'mailShouldSend',
       'mustEncrypt',
       'mailStamps',
       'mailUnexpandablePHIDs',
       'mailMutedPHIDs',
       'webhookMap',
       'silent',
       'sendHistory',
     );
   }
 
   /**
    * Apply encodings prior to storage.
    *
    * See @{method:getCustomWorkerStateEncoding}.
    *
-   * @param map<string, wild> Map of values to encode.
-   * @param map<string, string> Map of encodings to apply.
+   * @param map<string, wild> $state Map of values to encode.
+   * @param map<string, string> $encodings Map of encodings to apply.
    * @return map<string, wild> Map of encoded values.
    * @task workers
    */
   private function encodeStateForStorage(
     array $state,
     array $encodings) {
 
     foreach ($state as $key => $value) {
       $encoding = idx($encodings, $key);
       switch ($encoding) {
         case self::STORAGE_ENCODING_BINARY:
           // The mechanics of this encoding (serialize + base64) are a little
           // awkward, but it allows us encode arrays and still be JSON-safe
           // with binary data.
 
           $value = @serialize($value);
           if ($value === false) {
             throw new Exception(
               pht(
                 'Failed to serialize() value for key "%s".',
                 $key));
           }
 
           $value = base64_encode($value);
           if ($value === false) {
             throw new Exception(
               pht(
                 'Failed to base64 encode value for key "%s".',
                 $key));
           }
           break;
       }
       $state[$key] = $value;
     }
 
     return $state;
   }
 
 
   /**
    * Undo storage encoding applied when storing state.
    *
    * See @{method:getCustomWorkerStateEncoding}.
    *
-   * @param map<string, wild> Map of encoded values.
-   * @param map<string, string> Map of encodings.
+   * @param map<string, wild> $state Map of encoded values.
+   * @param map<string, string> $encodings Map of encodings.
    * @return map<string, wild> Map of decoded values.
    * @task workers
    */
   private function decodeStateFromStorage(
     array $state,
     array $encodings) {
 
     foreach ($state as $key => $value) {
       $encoding = idx($encodings, $key);
       switch ($encoding) {
         case self::STORAGE_ENCODING_BINARY:
           $value = base64_decode($value);
           if ($value === false) {
             throw new Exception(
               pht(
                 'Failed to base64_decode() value for key "%s".',
                 $key));
           }
 
           $value = unserialize($value);
           break;
       }
       $state[$key] = $value;
     }
 
     return $state;
   }
 
 
   /**
    * Remove conflicts from a list of projects.
    *
    * Objects aren't allowed to be tagged with multiple milestones in the same
    * group, nor projects such that one tag is the ancestor of any other tag.
    * If the list of PHIDs include mutually exclusive projects, remove the
    * conflicting projects.
    *
-   * @param list<phid> List of project PHIDs.
+   * @param list<phid> $phids List of project PHIDs.
    * @return list<phid> List with conflicts removed.
    */
   private function applyProjectConflictRules(array $phids) {
     if (!$phids) {
       return array();
     }
 
     // Overall, the last project in the list wins in cases of conflict (so when
     // you add something, the thing you just added sticks and removes older
     // values).
 
     // Beyond that, there are two basic cases:
 
     // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
     // If multiple projects are milestones of the same parent, we only keep the
     // last one.
 
     // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
     // in the list, we remove "A" and keep "A > B". If "A" comes later, we
     // remove "A > B" and keep "A".
 
     // Note that it's OK to be in "A > B" and "A > C". There's only a conflict
     // if one project is an ancestor of another. It's OK to have something
     // tagged with multiple projects which share a common ancestor, so long as
     // they are not mutual ancestors.
 
     $viewer = PhabricatorUser::getOmnipotentUser();
 
     $projects = id(new PhabricatorProjectQuery())
       ->setViewer($viewer)
       ->withPHIDs(array_keys($phids))
       ->execute();
     $projects = mpull($projects, null, 'getPHID');
 
     // We're going to build a map from each project with milestones to the last
     // milestone in the list. This last milestone is the milestone we'll keep.
     $milestone_map = array();
 
     // We're going to build a set of the projects which have no descendants
     // later in the list. This allows us to apply both ancestor rules.
     $ancestor_map = array();
 
     foreach ($phids as $phid => $ignored) {
       $project = idx($projects, $phid);
       if (!$project) {
         continue;
       }
 
       // This is the last milestone we've seen, so set it as the selection for
       // the project's parent. This might be setting a new value or overwriting
       // an earlier value.
       if ($project->isMilestone()) {
         $parent_phid = $project->getParentProjectPHID();
         $milestone_map[$parent_phid] = $phid;
       }
 
       // Since this is the last item in the list we've examined so far, add it
       // to the set of projects with no later descendants.
       $ancestor_map[$phid] = $phid;
 
       // Remove any ancestors from the set, since this is a later descendant.
       foreach ($project->getAncestorProjects() as $ancestor) {
         $ancestor_phid = $ancestor->getPHID();
         unset($ancestor_map[$ancestor_phid]);
       }
     }
 
     // Now that we've built the maps, we can throw away all the projects which
     // have conflicts.
     foreach ($phids as $phid => $ignored) {
       $project = idx($projects, $phid);
 
       if (!$project) {
         // If a PHID is invalid, we just leave it as-is. We could clean it up,
         // but leaving it untouched is less likely to cause collateral damage.
         continue;
       }
 
       // If this was a milestone, check if it was the last milestone from its
       // group in the list. If not, remove it from the list.
       if ($project->isMilestone()) {
         $parent_phid = $project->getParentProjectPHID();
         if ($milestone_map[$parent_phid] !== $phid) {
           unset($phids[$phid]);
           continue;
         }
       }
 
       // If a later project in the list is a subproject of this one, it will
       // have removed ancestors from the map. If this project does not point
       // at itself in the ancestor map, it should be discarded in favor of a
       // subproject that comes later.
       if (idx($ancestor_map, $phid) !== $phid) {
         unset($phids[$phid]);
         continue;
       }
 
       // If a later project in the list is an ancestor of this one, it will
       // have added itself to the map. If any ancestor of this project points
       // at itself in the map, this project should be discarded in favor of
       // that later ancestor.
       foreach ($project->getAncestorProjects() as $ancestor) {
         $ancestor_phid = $ancestor->getPHID();
         if (isset($ancestor_map[$ancestor_phid])) {
           unset($phids[$phid]);
           continue 2;
         }
       }
     }
 
     return $phids;
   }
 
   /**
    * When the view policy for an object is changed, scramble the secret keys
    * for attached files to invalidate existing URIs.
    */
   private function scrambleFileSecrets($object) {
     // If this is a newly created object, we don't need to scramble anything
     // since it couldn't have been previously published.
     if ($this->getIsNewObject()) {
       return;
     }
 
     // If the object is a file itself, scramble it.
     if ($object instanceof PhabricatorFile) {
       if ($this->shouldScramblePolicy($object->getViewPolicy())) {
         $object->scrambleSecret();
         $object->save();
       }
     }
 
     $omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
 
     $files = id(new PhabricatorFileQuery())
       ->setViewer($omnipotent_viewer)
       ->withAttachedObjectPHIDs(array($object->getPHID()))
       ->execute();
     foreach ($files as $file) {
       $view_policy = $file->getViewPolicy();
       if ($this->shouldScramblePolicy($view_policy)) {
         $file->scrambleSecret();
         $file->save();
       }
     }
   }
 
 
   /**
    * Check if a policy is strong enough to justify scrambling. Objects which
    * are set to very open policies don't need to scramble their files, and
    * files with very open policies don't need to be scrambled when associated
    * objects change.
    */
   private function shouldScramblePolicy($policy) {
     switch ($policy) {
       case PhabricatorPolicies::POLICY_PUBLIC:
       case PhabricatorPolicies::POLICY_USER:
         return false;
     }
 
     return true;
   }
 
   private function updateWorkboardColumns($object, $const, $old, $new) {
     // If an object is removed from a project, remove it from any proxy
     // columns for that project. This allows a task which is moved up from a
     // milestone to the parent to move back into the "Backlog" column on the
     // parent workboard.
 
     if ($const != PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) {
       return;
     }
 
     // TODO: This should likely be some future WorkboardInterface.
     $appears_on_workboards = ($object instanceof ManiphestTask);
     if (!$appears_on_workboards) {
       return;
     }
 
     $removed_phids = array_keys(array_diff_key($old, $new));
     if (!$removed_phids) {
       return;
     }
 
     // Find any proxy columns for the removed projects.
     $proxy_columns = id(new PhabricatorProjectColumnQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withProxyPHIDs($removed_phids)
       ->execute();
     if (!$proxy_columns) {
       return array();
     }
 
     $proxy_phids = mpull($proxy_columns, 'getPHID');
 
     $position_table = new PhabricatorProjectColumnPosition();
     $conn_w = $position_table->establishConnection('w');
 
     queryfx(
       $conn_w,
       'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
       $position_table->getTableName(),
       $object->getPHID(),
       $proxy_phids);
   }
 
   private function getModularTransactionTypes(
     PhabricatorLiskDAO $object) {
 
     if ($this->modularTypes === null) {
       $template = $object->getApplicationTransactionTemplate();
       if ($template instanceof PhabricatorModularTransaction) {
         $xtypes = $template->newModularTransactionTypes();
         foreach ($xtypes as $key => $xtype) {
           $xtype = clone $xtype;
           $xtype->setEditor($this);
           $xtypes[$key] = $xtype;
         }
       } else {
         $xtypes = array();
       }
 
       $this->modularTypes = $xtypes;
     }
 
     return $this->modularTypes;
   }
 
   private function getModularTransactionType($object, $type) {
     $types = $this->getModularTransactionTypes($object);
     return idx($types, $type);
   }
 
   public function getCreateObjectTitle($author, $object) {
     return pht('%s created this object.', $author);
   }
 
   public function getCreateObjectTitleForFeed($author, $object) {
     return pht('%s created an object: %s.', $author, $object);
   }
 
 /* -(  Queue  )-------------------------------------------------------------- */
 
   protected function queueTransaction(
     PhabricatorApplicationTransaction $xaction) {
     $this->transactionQueue[] = $xaction;
     return $this;
   }
 
   private function flushTransactionQueue($object) {
     if (!$this->transactionQueue) {
       return;
     }
 
     $xactions = $this->transactionQueue;
     $this->transactionQueue = array();
 
     $editor = $this->newEditorCopy();
 
     return $editor->applyTransactions($object, $xactions);
   }
 
   final protected function newSubEditor(
     PhabricatorApplicationTransactionEditor $template = null) {
     $editor = $this->newEditorCopy($template);
 
     $editor->parentEditor = $this;
     $this->subEditors[] = $editor;
 
     return $editor;
   }
 
   private function newEditorCopy(
     PhabricatorApplicationTransactionEditor $template = null) {
     if ($template === null) {
       $template = newv(get_class($this), array());
     }
 
     $editor = id(clone $template)
       ->setActor($this->getActor())
       ->setContentSource($this->getContentSource())
       ->setContinueOnNoEffect($this->getContinueOnNoEffect())
       ->setContinueOnMissingFields($this->getContinueOnMissingFields())
       ->setParentMessageID($this->getParentMessageID())
       ->setIsSilent($this->getIsSilent());
 
     if ($this->actingAsPHID !== null) {
       $editor->setActingAsPHID($this->actingAsPHID);
     }
 
     $editor->mustEncrypt = $this->mustEncrypt;
     $editor->transactionGroupID = $this->getTransactionGroupID();
 
     return $editor;
   }
 
 
 /* -(  Stamps  )------------------------------------------------------------- */
 
 
   public function newMailStampTemplates($object) {
     $actor = $this->getActor();
 
     $templates = array();
 
     $extensions = $this->newMailExtensions($object);
     foreach ($extensions as $extension) {
       $stamps = $extension->newMailStampTemplates($object);
       foreach ($stamps as $stamp) {
         $key = $stamp->getKey();
         if (isset($templates[$key])) {
           throw new Exception(
             pht(
               'Mail extension ("%s") defines a stamp template with the '.
               'same key ("%s") as another template. Each stamp template '.
               'must have a unique key.',
               get_class($extension),
               $key));
         }
 
         $stamp->setViewer($actor);
 
         $templates[$key] = $stamp;
       }
     }
 
     return $templates;
   }
 
   final public function getMailStamp($key) {
     if (!isset($this->stampTemplates)) {
       throw new PhutilInvalidStateException('newMailStampTemplates');
     }
 
     if (!isset($this->stampTemplates[$key])) {
       throw new Exception(
         pht(
           'Editor ("%s") has no mail stamp template with provided key ("%s").',
           get_class($this),
           $key));
     }
 
     return $this->stampTemplates[$key];
   }
 
   private function newMailStamps($object, array $xactions) {
     $actor = $this->getActor();
 
     $this->stampTemplates = $this->newMailStampTemplates($object);
 
     $extensions = $this->newMailExtensions($object);
     $stamps = array();
     foreach ($extensions as $extension) {
       $extension->newMailStamps($object, $xactions);
     }
 
     return $this->stampTemplates;
   }
 
   private function newMailExtensions($object) {
     $actor = $this->getActor();
 
     $all_extensions = PhabricatorMailEngineExtension::getAllExtensions();
 
     $extensions = array();
     foreach ($all_extensions as $key => $template) {
       $extension = id(clone $template)
         ->setViewer($actor)
         ->setEditor($this);
 
       if ($extension->supportsObject($object)) {
         $extensions[$key] = $extension;
       }
     }
 
     return $extensions;
   }
 
   protected function newAuxiliaryMail($object, array $xactions) {
     return array();
   }
 
   private function generateMailStamps($object, $data) {
     if (!$data || !is_array($data)) {
       return null;
     }
 
     $templates = $this->newMailStampTemplates($object);
     foreach ($data as $spec) {
       if (!is_array($spec)) {
         continue;
       }
 
       $key = idx($spec, 'key');
       if (!isset($templates[$key])) {
         continue;
       }
 
       $type = idx($spec, 'type');
       if ($templates[$key]->getStampType() !== $type) {
         continue;
       }
 
       $value = idx($spec, 'value');
       $templates[$key]->setValueFromDictionary($value);
     }
 
     $results = array();
     foreach ($templates as $template) {
       $value = $template->getValueForRendering();
 
       $rendered = $template->renderStamps($value);
       if ($rendered === null) {
         continue;
       }
 
       $rendered = (array)$rendered;
       foreach ($rendered as $stamp) {
         $results[] = $stamp;
       }
     }
 
     natcasesort($results);
 
     return $results;
   }
 
   public function getRemovedRecipientPHIDs() {
     return $this->mailRemovedPHIDs;
   }
 
   private function buildOldRecipientLists($object, $xactions) {
     // See T4776. Before we start making any changes, build a list of the old
     // recipients. If a change removes a user from the recipient list for an
     // object we still want to notify the user about that change. This allows
     // them to respond if they didn't want to be removed.
 
     if (!$this->shouldSendMail($object, $xactions)) {
       return;
     }
 
     $this->oldTo = $this->getMailTo($object);
     $this->oldCC = $this->getMailCC($object);
 
     return $this;
   }
 
   private function applyOldRecipientLists() {
     $actor_phid = $this->getActingAsPHID();
 
     // If you took yourself off the recipient list (for example, by
     // unsubscribing or resigning) assume that you know what you did and
     // don't need to be notified.
 
     // If you just moved from "To" to "Cc" (or vice versa), you're still a
     // recipient so we don't need to add you back in.
 
     $map = array_fuse($this->mailToPHIDs) + array_fuse($this->mailCCPHIDs);
 
     foreach ($this->oldTo as $phid) {
       if ($phid === $actor_phid) {
         continue;
       }
 
       if (isset($map[$phid])) {
         continue;
       }
 
       $this->mailToPHIDs[] = $phid;
       $this->mailRemovedPHIDs[] = $phid;
     }
 
     foreach ($this->oldCC as $phid) {
       if ($phid === $actor_phid) {
         continue;
       }
 
       if (isset($map[$phid])) {
         continue;
       }
 
       $this->mailCCPHIDs[] = $phid;
       $this->mailRemovedPHIDs[] = $phid;
     }
 
     return $this;
   }
 
   private function queueWebhooks($object, array $xactions) {
     $hook_viewer = PhabricatorUser::getOmnipotentUser();
 
     $webhook_map = $this->webhookMap;
     if (!is_array($webhook_map)) {
       $webhook_map = array();
     }
 
     // Add any "Firehose" hooks to the list of hooks we're going to call.
     $firehose_hooks = id(new HeraldWebhookQuery())
       ->setViewer($hook_viewer)
       ->withStatuses(
         array(
           HeraldWebhook::HOOKSTATUS_FIREHOSE,
         ))
       ->execute();
     foreach ($firehose_hooks as $firehose_hook) {
       // This is "the hook itself is the reason this hook is being called",
       // since we're including it because it's configured as a firehose
       // hook.
       $hook_phid = $firehose_hook->getPHID();
       $webhook_map[$hook_phid][] = $hook_phid;
     }
 
     if (!$webhook_map) {
       return;
     }
 
     // NOTE: We're going to queue calls to disabled webhooks, they'll just
     // immediately fail in the worker queue. This makes the behavior more
     // visible.
 
     $call_hooks = id(new HeraldWebhookQuery())
       ->setViewer($hook_viewer)
       ->withPHIDs(array_keys($webhook_map))
       ->execute();
 
     foreach ($call_hooks as $call_hook) {
       $trigger_phids = idx($webhook_map, $call_hook->getPHID());
 
       $request = HeraldWebhookRequest::initializeNewWebhookRequest($call_hook)
         ->setObjectPHID($object->getPHID())
         ->setTransactionPHIDs(mpull($xactions, 'getPHID'))
         ->setTriggerPHIDs($trigger_phids)
         ->setRetryMode(HeraldWebhookRequest::RETRY_FOREVER)
         ->setIsSilentAction((bool)$this->getIsSilent())
         ->setIsSecureAction((bool)$this->getMustEncrypt())
         ->save();
 
       $request->queueCall();
     }
   }
 
   private function hasWarnings($object, $xaction) {
     // TODO: For the moment, this is a very un-modular hack to support
     // a small number of warnings related to draft revisions. See PHI433.
 
     if (!($object instanceof DifferentialRevision)) {
       return false;
     }
 
     $type = $xaction->getTransactionType();
 
     // TODO: This doesn't warn for inlines in Audit, even though they have
     // the same overall workflow.
     if ($type === DifferentialTransaction::TYPE_INLINE) {
       return (bool)$xaction->getComment()->getAttribute('editing', false);
     }
 
     if (!$object->isDraft()) {
       return false;
     }
 
     if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) {
       return false;
     }
 
     // We're only going to raise a warning if the transaction adds subscribers
     // other than the acting user. (This implementation is clumsy because the
     // code runs before a lot of normalization occurs.)
 
     $old = $this->getTransactionOldValue($object, $xaction);
     $new = $this->getPHIDTransactionNewValue($xaction, $old);
     $old = array_fuse($old);
     $new = array_fuse($new);
     $add = array_diff_key($new, $old);
 
     unset($add[$this->getActingAsPHID()]);
 
     if (!$add) {
       return false;
     }
 
     return true;
   }
 
   private function buildHistoryMail(PhabricatorLiskDAO $object) {
     $viewer = $this->requireActor();
     $recipient_phid = $this->getActingAsPHID();
 
     // Load every transaction so we can build a mail message with a complete
     // history for the object.
     $query = PhabricatorApplicationTransactionQuery::newQueryForObject($object);
     $xactions = $query
       ->setViewer($viewer)
       ->withObjectPHIDs(array($object->getPHID()))
       ->execute();
     $xactions = array_reverse($xactions);
 
     $mail_messages = $this->buildMailWithRecipients(
       $object,
       $xactions,
       array($recipient_phid),
       array(),
       array());
     $mail = head($mail_messages);
 
     // Since the user explicitly requested "!history", force delivery of this
     // message regardless of their other mail settings.
     $mail->setForceDelivery(true);
 
     return $mail;
   }
 
   public function newAutomaticInlineTransactions(
     PhabricatorLiskDAO $object,
     $transaction_type,
     PhabricatorCursorPagedPolicyAwareQuery $query_template) {
 
     $actor = $this->getActor();
 
     $inlines = id(clone $query_template)
       ->setViewer($actor)
       ->withObjectPHIDs(array($object->getPHID()))
       ->withPublishableComments(true)
       ->needAppliedDrafts(true)
       ->needReplyToComments(true)
       ->execute();
     $inlines = msort($inlines, 'getID');
 
     $xactions = array();
 
     foreach ($inlines as $key => $inline) {
       $xactions[] = $object->getApplicationTransactionTemplate()
         ->setTransactionType($transaction_type)
         ->attachComment($inline);
     }
 
     $state_xaction = $this->newInlineStateTransaction(
       $object,
       $query_template);
 
     if ($state_xaction) {
       $xactions[] = $state_xaction;
     }
 
     return $xactions;
   }
 
   protected function newInlineStateTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorCursorPagedPolicyAwareQuery $query_template) {
 
     $actor_phid = $this->getActingAsPHID();
     $author_phid = $object->getAuthorPHID();
     $actor_is_author = ($actor_phid == $author_phid);
 
     $state_map = PhabricatorTransactions::getInlineStateMap();
 
     $inline_query = id(clone $query_template)
       ->setViewer($this->getActor())
       ->withObjectPHIDs(array($object->getPHID()))
       ->withFixedStates(array_keys($state_map))
       ->withPublishableComments(true);
 
     if ($actor_is_author) {
       $inline_query->withPublishedComments(true);
     }
 
     $inlines = $inline_query->execute();
 
     if (!$inlines) {
       return null;
     }
 
     $old_value = mpull($inlines, 'getFixedState', 'getPHID');
     $new_value = array();
     foreach ($old_value as $key => $state) {
       $new_value[$key] = $state_map[$state];
     }
 
     // See PHI995. Copy some information about the inlines into the transaction
     // so we can tailor rendering behavior. In particular, we don't want to
     // render transactions about users marking their own inlines as "Done".
 
     $inline_details = array();
     foreach ($inlines as $inline) {
       $inline_details[$inline->getPHID()] = array(
         'authorPHID' => $inline->getAuthorPHID(),
       );
     }
 
     return $object->getApplicationTransactionTemplate()
       ->setTransactionType(PhabricatorTransactions::TYPE_INLINESTATE)
       ->setIgnoreOnNoEffect(true)
       ->setMetadataValue('inline.details', $inline_details)
       ->setOldValue($old_value)
       ->setNewValue($new_value);
   }
 
   private function requireMFA(PhabricatorLiskDAO $object, array $xactions) {
     $actor = $this->getActor();
 
     // Let omnipotent editors skip MFA. This is mostly aimed at scripts.
     if ($actor->isOmnipotent()) {
       return;
     }
 
     $editor_class = get_class($this);
 
     $object_phid = $object->getPHID();
     if ($object_phid) {
       $workflow_key = sprintf(
         'editor(%s).phid(%s)',
         $editor_class,
         $object_phid);
     } else {
       $workflow_key = sprintf(
         'editor(%s).new()',
         $editor_class);
     }
 
     $request = $this->getRequest();
     if ($request === null) {
       $source_type = $this->getContentSource()->getSourceTypeConstant();
       $conduit_type = PhabricatorConduitContentSource::SOURCECONST;
       $is_conduit = ($source_type === $conduit_type);
       if ($is_conduit) {
         throw new Exception(
           pht(
             'This transaction group requires MFA to apply, but you can not '.
             'provide an MFA response via Conduit. Edit this object via the '.
             'web UI.'));
       } else {
         throw new Exception(
           pht(
             'This transaction group requires MFA to apply, but the Editor was '.
             'not configured with a Request. This workflow can not perform an '.
             'MFA check.'));
       }
     }
 
     $cancel_uri = $this->getCancelURI();
     if ($cancel_uri === null) {
       throw new Exception(
         pht(
           'This transaction group requires MFA to apply, but the Editor was '.
           'not configured with a Cancel URI. This workflow can not perform '.
           'an MFA check.'));
     }
 
     $token = id(new PhabricatorAuthSessionEngine())
       ->setWorkflowKey($workflow_key)
       ->requireHighSecurityToken($actor, $request, $cancel_uri);
 
     if (!$token->getIsUnchallengedToken()) {
       foreach ($xactions as $xaction) {
         $xaction->setIsMFATransaction(true);
       }
     }
   }
 
   private function newMFATransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface);
     if ($has_engine) {
       $engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
         ->setViewer($this->getActor());
       $require_mfa = $engine->shouldRequireMFA();
       $try_mfa = $engine->shouldTryMFA();
     } else {
       $require_mfa = false;
       $try_mfa = false;
     }
 
     // If the user is mentioning an MFA object on another object or creating
     // a relationship like "parent" or "child" to this object, we always
     // allow the edit to move forward without requiring MFA.
     if ($this->getIsInverseEdgeEditor()) {
       return $xactions;
     }
 
     if (!$require_mfa) {
       // If the object hasn't already opted into MFA, see if any of the
       // transactions want it.
       if (!$try_mfa) {
         foreach ($xactions as $xaction) {
           $type = $xaction->getTransactionType();
 
           $xtype = $this->getModularTransactionType($object, $type);
           if ($xtype) {
             $xtype = clone $xtype;
             $xtype->setStorage($xaction);
             if ($xtype->shouldTryMFA($object, $xaction)) {
               $try_mfa = true;
               break;
             }
           }
         }
       }
 
       if ($try_mfa) {
         $this->setShouldRequireMFA(true);
       }
 
       return $xactions;
     }
 
     $type_mfa = PhabricatorTransactions::TYPE_MFA;
 
     $has_mfa = false;
     foreach ($xactions as $xaction) {
       if ($xaction->getTransactionType() === $type_mfa) {
         $has_mfa = true;
         break;
       }
     }
 
     if ($has_mfa) {
       return $xactions;
     }
 
     $template = $object->getApplicationTransactionTemplate();
 
     $mfa_xaction = id(clone $template)
       ->setTransactionType($type_mfa)
       ->setNewValue(true);
 
     array_unshift($xactions, $mfa_xaction);
 
     return $xactions;
   }
 
   private function getTitleForTextMail(
     PhabricatorApplicationTransaction $xaction) {
     $type = $xaction->getTransactionType();
     $object = $this->object;
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       $xtype = clone $xtype;
       $xtype->setStorage($xaction);
       $comment = $xtype->getTitleForTextMail();
       if ($comment !== false) {
         return $comment;
       }
     }
 
     return $xaction->getTitleForTextMail();
   }
 
   private function getTitleForHTMLMail(
     PhabricatorApplicationTransaction $xaction) {
     $type = $xaction->getTransactionType();
     $object = $this->object;
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       $xtype = clone $xtype;
       $xtype->setStorage($xaction);
       $comment = $xtype->getTitleForHTMLMail();
       if ($comment !== false) {
         return $comment;
       }
     }
 
     return $xaction->getTitleForHTMLMail();
   }
 
 
   private function getBodyForTextMail(
     PhabricatorApplicationTransaction $xaction) {
     $type = $xaction->getTransactionType();
     $object = $this->object;
 
     $xtype = $this->getModularTransactionType($object, $type);
     if ($xtype) {
       $xtype = clone $xtype;
       $xtype->setStorage($xaction);
       $comment = $xtype->getBodyForTextMail();
       if ($comment !== false) {
         return $comment;
       }
     }
 
     return $xaction->getBodyForMail();
   }
 
   private function isLockOverrideTransaction(
     PhabricatorApplicationTransaction $xaction) {
 
     // See PHI1209. When an object is locked, certain types of transactions
     // can still be applied without requiring a policy check, like subscribing
     // or unsubscribing. We don't want these transactions to show the "Lock
     // Override" icon in the transaction timeline.
 
     // We could test if a transaction did no direct policy checks, but it may
     // have done additional policy checks during validation, so this is not a
     // reliable test (and could cause false negatives, where edits which did
     // override a lock are not marked properly).
 
     // For now, do this in a narrow way and just check against a hard-coded
     // list of non-override transaction situations. Some day, this should
     // likely be modularized.
 
 
     // Inverse edge edits don't interact with locks.
     if ($this->getIsInverseEdgeEditor()) {
       return false;
     }
 
     // For now, all edits other than subscribes always override locks.
     $type = $xaction->getTransactionType();
     if ($type !== PhabricatorTransactions::TYPE_SUBSCRIBERS) {
       return true;
     }
 
     // Subscribes override locks if they affect any users other than the
     // acting user.
 
     $acting_phid = $this->getActingAsPHID();
 
     $old = array_fuse($xaction->getOldValue());
     $new = array_fuse($xaction->getNewValue());
     $add = array_diff_key($new, $old);
     $rem = array_diff_key($old, $new);
 
     $all = $add + $rem;
     foreach ($all as $phid) {
       if ($phid !== $acting_phid) {
         return true;
       }
     }
 
     return false;
   }
 
 
 /* -(  Extensions  )--------------------------------------------------------- */
 
 
   private function validateTransactionsWithExtensions(
     PhabricatorLiskDAO $object,
     array $xactions) {
     $errors = array();
 
     $extensions = $this->getEditorExtensions();
     foreach ($extensions as $extension) {
       $extension_errors = $extension
         ->setObject($object)
         ->validateTransactions($object, $xactions);
 
       assert_instances_of(
         $extension_errors,
         'PhabricatorApplicationTransactionValidationError');
 
       $errors[] = $extension_errors;
     }
 
     return array_mergev($errors);
   }
 
   private function getEditorExtensions() {
     if ($this->extensions === null) {
       $this->extensions = $this->newEditorExtensions();
     }
     return $this->extensions;
   }
 
   private function newEditorExtensions() {
     $extensions = PhabricatorEditorExtension::getAllExtensions();
 
     $actor = $this->getActor();
     $object = $this->object;
     foreach ($extensions as $key => $extension) {
 
       $extension = id(clone $extension)
         ->setViewer($actor)
         ->setEditor($this)
         ->setObject($object);
 
       if (!$extension->supportsObject($this, $object)) {
         unset($extensions[$key]);
         continue;
       }
 
       $extensions[$key] = $extension;
     }
 
     return $extensions;
   }
 
 
 }
diff --git a/src/applications/transactions/storage/PhabricatorApplicationTransaction.php b/src/applications/transactions/storage/PhabricatorApplicationTransaction.php
index bfaa252d40..5c70f59a89 100644
--- a/src/applications/transactions/storage/PhabricatorApplicationTransaction.php
+++ b/src/applications/transactions/storage/PhabricatorApplicationTransaction.php
@@ -1,2054 +1,2054 @@
 <?php
 
 abstract class PhabricatorApplicationTransaction
   extends PhabricatorLiskDAO
   implements
     PhabricatorPolicyInterface,
     PhabricatorDestructibleInterface {
 
   const TARGET_TEXT = 'text';
   const TARGET_HTML = 'html';
 
   protected $phid;
   protected $objectPHID;
   protected $authorPHID;
   protected $viewPolicy;
   protected $editPolicy;
 
   protected $commentPHID;
   protected $commentVersion = 0;
   protected $transactionType;
   protected $oldValue;
   protected $newValue;
   protected $metadata = array();
 
   protected $contentSource;
 
   private $comment;
   private $commentNotLoaded;
 
   private $handles;
   private $renderingTarget = self::TARGET_HTML;
   private $transactionGroup = array();
   private $viewer = self::ATTACHABLE;
   private $object = self::ATTACHABLE;
   private $oldValueHasBeenSet = false;
 
   private $ignoreOnNoEffect;
 
 
   /**
    * Flag this transaction as a pure side-effect which should be ignored when
    * applying transactions if it has no effect, even if transaction application
    * would normally fail. This both provides users with better error messages
    * and allows transactions to perform optional side effects.
    */
   public function setIgnoreOnNoEffect($ignore) {
     $this->ignoreOnNoEffect = $ignore;
     return $this;
   }
 
   public function getIgnoreOnNoEffect() {
     return $this->ignoreOnNoEffect;
   }
 
   public function shouldGenerateOldValue() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_TOKEN:
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
       case PhabricatorTransactions::TYPE_INLINESTATE:
         return false;
     }
     return true;
   }
 
   abstract public function getApplicationTransactionType();
 
   private function getApplicationObjectTypeName() {
     $types = PhabricatorPHIDType::getAllTypes();
 
     $type = idx($types, $this->getApplicationTransactionType());
     if ($type) {
       return $type->getTypeName();
     }
 
     return pht('Object');
   }
 
   public function getApplicationTransactionCommentObject() {
     return null;
   }
 
   public function getMetadataValue($key, $default = null) {
     return idx($this->metadata, $key, $default);
   }
 
   public function setMetadataValue($key, $value) {
     $this->metadata[$key] = $value;
     return $this;
   }
 
   public function generatePHID() {
     $type = PhabricatorApplicationTransactionTransactionPHIDType::TYPECONST;
     $subtype = $this->getApplicationTransactionType();
 
     return PhabricatorPHID::generateNewPHID($type, $subtype);
   }
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_SERIALIZATION => array(
         'oldValue' => self::SERIALIZATION_JSON,
         'newValue' => self::SERIALIZATION_JSON,
         'metadata' => self::SERIALIZATION_JSON,
       ),
       self::CONFIG_COLUMN_SCHEMA => array(
         'commentPHID' => 'phid?',
         'commentVersion' => 'uint32',
         'contentSource' => 'text',
         'transactionType' => 'text32',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_object' => array(
           'columns' => array('objectPHID'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function setContentSource(PhabricatorContentSource $content_source) {
     $this->contentSource = $content_source->serialize();
     return $this;
   }
 
   public function getContentSource() {
     return PhabricatorContentSource::newFromSerialized($this->contentSource);
   }
 
   public function hasComment() {
     $comment = $this->getComment();
     if (!$comment) {
       return false;
     }
 
     if ($comment->isEmptyComment()) {
       return false;
     }
 
     return true;
   }
 
   public function getComment() {
     if ($this->commentNotLoaded) {
       throw new Exception(pht('Comment for this transaction was not loaded.'));
     }
     return $this->comment;
   }
 
   public function setIsCreateTransaction($create) {
     return $this->setMetadataValue('core.create', $create);
   }
 
   public function getIsCreateTransaction() {
     return (bool)$this->getMetadataValue('core.create', false);
   }
 
   public function setIsDefaultTransaction($default) {
     return $this->setMetadataValue('core.default', $default);
   }
 
   public function getIsDefaultTransaction() {
     return (bool)$this->getMetadataValue('core.default', false);
   }
 
   public function setIsSilentTransaction($silent) {
     return $this->setMetadataValue('core.silent', $silent);
   }
 
   public function getIsSilentTransaction() {
     return (bool)$this->getMetadataValue('core.silent', false);
   }
 
   public function setIsMFATransaction($mfa) {
     return $this->setMetadataValue('core.mfa', $mfa);
   }
 
   public function getIsMFATransaction() {
     return (bool)$this->getMetadataValue('core.mfa', false);
   }
 
   public function setIsLockOverrideTransaction($override) {
     return $this->setMetadataValue('core.lock-override', $override);
   }
 
   public function getIsLockOverrideTransaction() {
     return (bool)$this->getMetadataValue('core.lock-override', false);
   }
 
   public function setTransactionGroupID($group_id) {
     return $this->setMetadataValue('core.groupID', $group_id);
   }
 
   public function getTransactionGroupID() {
     return $this->getMetadataValue('core.groupID', null);
   }
 
   public function attachComment(
     PhabricatorApplicationTransactionComment $comment) {
     $this->comment = $comment;
     $this->commentNotLoaded = false;
     return $this;
   }
 
   public function setCommentNotLoaded($not_loaded) {
     $this->commentNotLoaded = $not_loaded;
     return $this;
   }
 
   public function attachObject($object) {
     $this->object = $object;
     return $this;
   }
 
   public function getObject() {
     return $this->assertAttached($this->object);
   }
 
   public function getRemarkupChanges() {
     $changes = $this->newRemarkupChanges();
     assert_instances_of($changes, 'PhabricatorTransactionRemarkupChange');
 
     // Convert older-style remarkup blocks into newer-style remarkup changes.
     // This builds changes that do not have the correct "old value", so rules
     // that operate differently against edits (like @user mentions) won't work
     // properly.
     foreach ($this->getRemarkupBlocks() as $block) {
       $changes[] = $this->newRemarkupChange()
         ->setOldValue(null)
         ->setNewValue($block);
     }
 
     $comment = $this->getComment();
     if ($comment) {
       if ($comment->hasOldComment()) {
         $old_value = $comment->getOldComment()->getContent();
       } else {
         $old_value = null;
       }
 
       $new_value = $comment->getContent();
 
       $changes[] = $this->newRemarkupChange()
         ->setOldValue($old_value)
         ->setNewValue($new_value);
     }
 
     $metadata = $this->getMetadataValue('remarkup.control');
 
     if (!is_array($metadata)) {
       $metadata = array();
     }
 
     foreach ($changes as $change) {
       if (!$change->getMetadata()) {
         $change->setMetadata($metadata);
       }
     }
 
     return $changes;
   }
 
   protected function newRemarkupChanges() {
     return array();
   }
 
   protected function newRemarkupChange() {
     return id(new PhabricatorTransactionRemarkupChange())
       ->setTransaction($this);
   }
 
   /**
    * @deprecated
    */
   public function getRemarkupBlocks() {
     $blocks = array();
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           $custom_blocks = $field->getApplicationTransactionRemarkupBlocks(
             $this);
           foreach ($custom_blocks as $custom_block) {
             $blocks[] = $custom_block;
           }
         }
         break;
     }
 
     return $blocks;
   }
 
   public function setOldValue($value) {
     $this->oldValueHasBeenSet = true;
     $this->writeField('oldValue', $value);
     return $this;
   }
 
   public function hasOldValue() {
     return $this->oldValueHasBeenSet;
   }
 
   public function newChronologicalSortVector() {
     return id(new PhutilSortVector())
       ->addInt((int)$this->getDateCreated())
       ->addInt((int)$this->getID());
   }
 
 /* -(  Rendering  )---------------------------------------------------------- */
 
   public function setRenderingTarget($rendering_target) {
     $this->renderingTarget = $rendering_target;
     return $this;
   }
 
   public function getRenderingTarget() {
     return $this->renderingTarget;
   }
 
   public function attachViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->assertAttached($this->viewer);
   }
 
   public function getRequiredHandlePHIDs() {
     $phids = array();
 
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     $phids[] = array($this->getAuthorPHID());
     $phids[] = array($this->getObjectPHID());
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           $phids[] = $field->getApplicationTransactionRequiredHandlePHIDs(
             $this);
         }
         break;
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         $phids[] = $old;
         $phids[] = $new;
         break;
       case PhabricatorTransactions::TYPE_FILE:
         $phids[] = array_keys($old + $new);
         break;
       case PhabricatorTransactions::TYPE_EDGE:
         $record = PhabricatorEdgeChangeRecord::newFromTransaction($this);
         $phids[] = $record->getChangedPHIDs();
         break;
       case PhabricatorTransactions::TYPE_COLUMNS:
         foreach ($new as $move) {
           $phids[] = array(
             $move['columnPHID'],
             $move['boardPHID'],
           );
           $phids[] = $move['fromColumnPHIDs'];
         }
         break;
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
         if (!PhabricatorPolicyQuery::isSpecialPolicy($old)) {
           $phids[] = array($old);
         }
         if (!PhabricatorPolicyQuery::isSpecialPolicy($new)) {
           $phids[] = array($new);
         }
         break;
       case PhabricatorTransactions::TYPE_SPACE:
         if ($old) {
           $phids[] = array($old);
         }
         if ($new) {
           $phids[] = array($new);
         }
         break;
       case PhabricatorTransactions::TYPE_TOKEN:
         break;
     }
 
     if ($this->getComment()) {
       $phids[] = array($this->getComment()->getAuthorPHID());
     }
 
     return array_mergev($phids);
   }
 
   public function setHandles(array $handles) {
     $this->handles = $handles;
     return $this;
   }
 
   public function getHandle($phid) {
     if (empty($this->handles[$phid])) {
       throw new Exception(
         pht(
           'Transaction ("%s", of type "%s") requires a handle ("%s") that it '.
           'did not load.',
           $this->getPHID(),
           $this->getTransactionType(),
           $phid));
     }
     return $this->handles[$phid];
   }
 
   public function getHandleIfExists($phid) {
     return idx($this->handles, $phid);
   }
 
   public function getHandles() {
     if ($this->handles === null) {
       throw new Exception(
         pht('Transaction requires handles and it did not load them.'));
     }
     return $this->handles;
   }
 
   public function renderHandleLink($phid) {
     if ($this->renderingTarget == self::TARGET_HTML) {
       return $this->getHandle($phid)->renderHovercardLink();
     } else {
       return $this->getHandle($phid)->getLinkName();
     }
   }
 
   public function renderHandleList(array $phids) {
     $links = array();
     foreach ($phids as $phid) {
       $links[] = $this->renderHandleLink($phid);
     }
     if ($this->renderingTarget == self::TARGET_HTML) {
       return phutil_implode_html(', ', $links);
     } else {
       return implode(', ', $links);
     }
   }
 
   private function renderSubscriberList(array $phids, $change_type) {
     if ($this->getRenderingTarget() == self::TARGET_TEXT) {
       return $this->renderHandleList($phids);
     } else {
       $handles = array_select_keys($this->getHandles(), $phids);
       return id(new SubscriptionListStringBuilder())
         ->setHandles($handles)
         ->setObjectPHID($this->getPHID())
         ->buildTransactionString($change_type);
     }
   }
 
   protected function renderPolicyName($phid, $state = 'old') {
     $policy = PhabricatorPolicy::newFromPolicyAndHandle(
       $phid,
       $this->getHandleIfExists($phid));
 
     $ref = $policy->newRef($this->getViewer());
 
     if ($this->renderingTarget == self::TARGET_HTML) {
       $output = $ref->newTransactionLink($state, $this);
     } else {
       $output = $ref->getPolicyDisplayName();
     }
 
     return $output;
   }
 
   public function getIcon() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         $comment = $this->getComment();
         if ($comment && $comment->getIsRemoved()) {
           return 'fa-trash';
         }
         return 'fa-comment';
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         $old = $this->getOldValue();
         $new = $this->getNewValue();
         $add = array_diff($new, $old);
         $rem = array_diff($old, $new);
         if ($add && $rem) {
           return 'fa-user';
         } else if ($add) {
           return 'fa-user-plus';
         } else if ($rem) {
           return 'fa-user-times';
         } else {
           return 'fa-user';
         }
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
         return 'fa-lock';
       case PhabricatorTransactions::TYPE_EDGE:
         switch ($this->getMetadataValue('edge:type')) {
           case DiffusionCommitRevertedByCommitEdgeType::EDGECONST:
             return 'fa-undo';
           case DiffusionCommitRevertsCommitEdgeType::EDGECONST:
             return 'fa-ambulance';
         }
         return 'fa-link';
       case PhabricatorTransactions::TYPE_TOKEN:
         return 'fa-trophy';
       case PhabricatorTransactions::TYPE_SPACE:
         return 'fa-th-large';
       case PhabricatorTransactions::TYPE_COLUMNS:
         return 'fa-columns';
       case PhabricatorTransactions::TYPE_MFA:
         return 'fa-vcard';
     }
 
     return 'fa-pencil';
   }
 
   public function getToken() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_TOKEN:
         $old = $this->getOldValue();
         $new = $this->getNewValue();
         if ($new) {
           $icon = substr($new, 10);
         } else {
           $icon = substr($old, 10);
         }
         return array($icon, !$this->getNewValue());
     }
 
     return array(null, null);
   }
 
   public function getColor() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT;
         $comment = $this->getComment();
         if ($comment && $comment->getIsRemoved()) {
           return 'grey';
         }
         break;
       case PhabricatorTransactions::TYPE_EDGE:
         switch ($this->getMetadataValue('edge:type')) {
           case DiffusionCommitRevertedByCommitEdgeType::EDGECONST:
             return 'pink';
           case DiffusionCommitRevertsCommitEdgeType::EDGECONST:
             return 'sky';
         }
         break;
       case PhabricatorTransactions::TYPE_MFA;
         return 'pink';
     }
     return null;
   }
 
   protected function getTransactionCustomField() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $key = $this->getMetadataValue('customfield:key');
         if (!$key) {
           return null;
         }
 
         $object = $this->getObject();
 
         if (!($object instanceof PhabricatorCustomFieldInterface)) {
           return null;
         }
 
         $field = PhabricatorCustomField::getObjectField(
           $object,
           PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
           $key);
         if (!$field) {
           return null;
         }
 
         $field->setViewer($this->getViewer());
         return $field;
     }
 
     return null;
   }
 
   public function shouldHide() {
     // Never hide comments.
     if ($this->hasComment()) {
       return false;
     }
 
     $xaction_type = $this->getTransactionType();
 
     // Always hide requests for object history.
     if ($xaction_type === PhabricatorTransactions::TYPE_HISTORY) {
       return true;
     }
 
     // Always hide file attach/detach transactions.
     if ($xaction_type === PhabricatorTransactions::TYPE_FILE) {
       if ($this->getMetadataValue('attach.implicit')) {
         return true;
       }
     }
 
     // Hide creation transactions if the old value is empty. These are
     // transactions like "alice set the task title to: ...", which are
     // essentially never interesting.
     if ($this->getIsCreateTransaction()) {
       switch ($xaction_type) {
         case PhabricatorTransactions::TYPE_CREATE:
         case PhabricatorTransactions::TYPE_VIEW_POLICY:
         case PhabricatorTransactions::TYPE_EDIT_POLICY:
         case PhabricatorTransactions::TYPE_JOIN_POLICY:
         case PhabricatorTransactions::TYPE_INTERACT_POLICY:
         case PhabricatorTransactions::TYPE_SPACE:
           break;
         case PhabricatorTransactions::TYPE_SUBTYPE:
           return true;
         default:
           $old = $this->getOldValue();
 
           if (is_array($old) && !$old) {
             return true;
           }
 
           if (!is_array($old)) {
             if ($old === '' || $old === null) {
               return true;
             }
 
             // The integer 0 is also uninteresting by default; this is often
             // an "off" flag for something like "All Day Event".
             if ($old === 0) {
               return true;
             }
           }
 
           break;
       }
     }
 
     // Hide creation transactions setting values to defaults, even if
     // the old value is not empty. For example, tasks may have a global
     // default view policy of "All Users", but a particular form sets the
     // policy to "Administrators". The transaction corresponding to this
     // change is not interesting, since it is the default behavior of the
     // form.
 
     if ($this->getIsCreateTransaction()) {
       if ($this->getIsDefaultTransaction()) {
         return true;
       }
     }
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
       case PhabricatorTransactions::TYPE_SPACE:
         if ($this->getIsCreateTransaction()) {
           break;
         }
 
         // TODO: Remove this eventually, this is handling old changes during
         // object creation prior to the introduction of "create" and "default"
         // transaction display flags.
 
         // NOTE: We can also hit this case with Space transactions that later
         // update a default space (`null`) to an explicit space, so handling
         // the Space case may require some finesse.
 
         if ($this->getOldValue() === null) {
           return true;
         } else {
           return false;
         }
         break;
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->shouldHideInApplicationTransactions($this);
         }
         break;
       case PhabricatorTransactions::TYPE_COLUMNS:
         return !$this->getInterestingMoves($this->getNewValue());
       case PhabricatorTransactions::TYPE_EDGE:
         $edge_type = $this->getMetadataValue('edge:type');
         switch ($edge_type) {
           case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
           case ManiphestTaskHasDuplicateTaskEdgeType::EDGECONST:
           case ManiphestTaskIsDuplicateOfTaskEdgeType::EDGECONST:
           case PhabricatorMutedEdgeType::EDGECONST:
           case PhabricatorMutedByEdgeType::EDGECONST:
             return true;
           case PhabricatorObjectMentionedByObjectEdgeType::EDGECONST:
             $record = PhabricatorEdgeChangeRecord::newFromTransaction($this);
             $add = $record->getAddedPHIDs();
             $add_value = reset($add);
             $add_handle = $this->getHandle($add_value);
             if ($add_handle->getPolicyFiltered()) {
               return true;
             }
             return false;
             break;
           default:
             break;
         }
         break;
 
       case PhabricatorTransactions::TYPE_INLINESTATE:
         list($done, $undone) = $this->getInterestingInlineStateChangeCounts();
 
         if (!$done && !$undone) {
           return true;
         }
 
         break;
 
     }
 
     return false;
   }
 
   public function shouldHideForMail(array $xactions) {
     if ($this->isSelfSubscription()) {
       return true;
     }
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_TOKEN:
         return true;
       case PhabricatorTransactions::TYPE_EDGE:
         $edge_type = $this->getMetadataValue('edge:type');
         switch ($edge_type) {
           case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
           case PhabricatorObjectMentionedByObjectEdgeType::EDGECONST:
           case DifferentialRevisionDependsOnRevisionEdgeType::EDGECONST:
           case DifferentialRevisionDependedOnByRevisionEdgeType::EDGECONST:
           case ManiphestTaskHasCommitEdgeType::EDGECONST:
           case DiffusionCommitHasTaskEdgeType::EDGECONST:
           case DiffusionCommitHasRevisionEdgeType::EDGECONST:
           case DifferentialRevisionHasCommitEdgeType::EDGECONST:
             return true;
           case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST:
             // When an object is first created, we hide any corresponding
             // project transactions in the web UI because you can just look at
             // the UI element elsewhere on screen to see which projects it
             // is tagged with. However, in mail there's no other way to get
             // this information, and it has some amount of value to users, so
             // we keep the transaction. See T10493.
             return false;
           default:
             break;
         }
         break;
     }
 
     if ($this->isInlineCommentTransaction()) {
       $inlines = array();
 
       // If there's a normal comment, we don't need to publish the inline
       // transaction, since the normal comment covers things.
       foreach ($xactions as $xaction) {
         if ($xaction->isInlineCommentTransaction()) {
           $inlines[] = $xaction;
           continue;
         }
 
         // We found a normal comment, so hide this inline transaction.
         if ($xaction->hasComment()) {
           return true;
         }
       }
 
       // If there are several inline comments, only publish the first one.
       if ($this !== head($inlines)) {
         return true;
       }
     }
 
     return $this->shouldHide();
   }
 
   public function shouldHideForFeed() {
     if ($this->isSelfSubscription()) {
       return true;
     }
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_TOKEN:
       case PhabricatorTransactions::TYPE_MFA:
         return true;
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         // See T8952. When an application (usually Herald) modifies
         // subscribers, this tends to be very uninteresting.
         if ($this->isApplicationAuthor()) {
           return true;
         }
         break;
       case PhabricatorTransactions::TYPE_EDGE:
         $edge_type = $this->getMetadataValue('edge:type');
         switch ($edge_type) {
           case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
           case PhabricatorObjectMentionedByObjectEdgeType::EDGECONST:
           case DifferentialRevisionDependsOnRevisionEdgeType::EDGECONST:
           case DifferentialRevisionDependedOnByRevisionEdgeType::EDGECONST:
           case ManiphestTaskHasCommitEdgeType::EDGECONST:
           case DiffusionCommitHasTaskEdgeType::EDGECONST:
           case DiffusionCommitHasRevisionEdgeType::EDGECONST:
           case DifferentialRevisionHasCommitEdgeType::EDGECONST:
             return true;
           default:
             break;
         }
         break;
      case PhabricatorTransactions::TYPE_INLINESTATE:
        return true;
     }
 
     return $this->shouldHide();
   }
 
   public function shouldHideForNotifications() {
     return $this->shouldHideForFeed();
   }
 
   private function getTitleForMailWithRenderingTarget($new_target) {
     $old_target = $this->getRenderingTarget();
     try {
       $this->setRenderingTarget($new_target);
       $result = $this->getTitleForMail();
     } catch (Exception $ex) {
       $this->setRenderingTarget($old_target);
       throw $ex;
     }
     $this->setRenderingTarget($old_target);
     return $result;
   }
 
   public function getTitleForMail() {
     return $this->getTitle();
   }
 
   public function getTitleForTextMail() {
     return $this->getTitleForMailWithRenderingTarget(self::TARGET_TEXT);
   }
 
   public function getTitleForHTMLMail() {
     // TODO: For now, rendering this with TARGET_HTML generates links with
     // bad targets ("/x/y/" instead of "https://dev.example.com/x/y/"). Throw
     // a rug over the issue for the moment. See T12921.
 
     $title = $this->getTitleForMailWithRenderingTarget(self::TARGET_TEXT);
     if ($title === null) {
       return null;
     }
 
     if ($this->hasChangeDetails()) {
       $details_uri = $this->getChangeDetailsURI();
       $details_uri = PhabricatorEnv::getProductionURI($details_uri);
 
       $show_details = phutil_tag(
         'a',
         array(
           'href' => $details_uri,
         ),
         pht('(Show Details)'));
 
       $title = array($title, ' ', $show_details);
     }
 
     return $title;
   }
 
   public function getChangeDetailsURI() {
     return '/transactions/detail/'.$this->getPHID().'/';
   }
 
   public function getBodyForMail() {
     if ($this->isInlineCommentTransaction()) {
       // We don't return inline comment content as mail body content, because
       // applications need to contextualize it (by adding line numbers, for
       // example) in order for it to make sense.
       return null;
     }
 
     $comment = $this->getComment();
     if ($comment && strlen($comment->getContent())) {
       return $comment->getContent();
     }
 
     return null;
   }
 
   public function getNoEffectDescription() {
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return pht('You can not post an empty comment.');
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         return pht(
           'This %s already has that view policy.',
           $this->getApplicationObjectTypeName());
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         return pht(
           'This %s already has that edit policy.',
           $this->getApplicationObjectTypeName());
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         return pht(
           'This %s already has that join policy.',
           $this->getApplicationObjectTypeName());
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
         return pht(
           'This %s already has that interact policy.',
           $this->getApplicationObjectTypeName());
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return pht(
           'All users are already subscribed to this %s.',
           $this->getApplicationObjectTypeName());
       case PhabricatorTransactions::TYPE_SPACE:
         return pht('This object is already in that space.');
       case PhabricatorTransactions::TYPE_EDGE:
         return pht('Edges already exist; transaction has no effect.');
       case PhabricatorTransactions::TYPE_COLUMNS:
         return pht(
           'You have not moved this object to any columns it is not '.
           'already in.');
       case PhabricatorTransactions::TYPE_MFA:
         return pht(
           'You can not sign a transaction group that has no other '.
           'effects.');
     }
 
     return pht(
       'Transaction (of type "%s") has no effect.',
       $this->getTransactionType());
   }
 
   public function getTitle() {
     $author_phid = $this->getAuthorPHID();
 
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CREATE:
         return pht(
           '%s created this object.',
           $this->renderHandleLink($author_phid));
       case PhabricatorTransactions::TYPE_COMMENT:
         return pht(
           '%s added a comment.',
           $this->renderHandleLink($author_phid));
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         if ($this->getIsCreateTransaction()) {
           return pht(
             '%s created this object with visibility "%s".',
             $this->renderHandleLink($author_phid),
             $this->renderPolicyName($new, 'new'));
         } else {
           return pht(
             '%s changed the visibility from "%s" to "%s".',
             $this->renderHandleLink($author_phid),
             $this->renderPolicyName($old, 'old'),
             $this->renderPolicyName($new, 'new'));
         }
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         if ($this->getIsCreateTransaction()) {
           return pht(
             '%s created this object with edit policy "%s".',
             $this->renderHandleLink($author_phid),
             $this->renderPolicyName($new, 'new'));
         } else {
           return pht(
             '%s changed the edit policy from "%s" to "%s".',
             $this->renderHandleLink($author_phid),
             $this->renderPolicyName($old, 'old'),
             $this->renderPolicyName($new, 'new'));
         }
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         if ($this->getIsCreateTransaction()) {
           return pht(
             '%s created this object with join policy "%s".',
             $this->renderHandleLink($author_phid),
             $this->renderPolicyName($new, 'new'));
         } else {
           return pht(
             '%s changed the join policy from "%s" to "%s".',
             $this->renderHandleLink($author_phid),
             $this->renderPolicyName($old, 'old'),
             $this->renderPolicyName($new, 'new'));
         }
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
         if ($this->getIsCreateTransaction()) {
           return pht(
             '%s created this object with interact policy "%s".',
             $this->renderHandleLink($author_phid),
             $this->renderPolicyName($new, 'new'));
         } else {
           return pht(
             '%s changed the interact policy from "%s" to "%s".',
             $this->renderHandleLink($author_phid),
             $this->renderPolicyName($old, 'old'),
             $this->renderPolicyName($new, 'new'));
         }
       case PhabricatorTransactions::TYPE_SPACE:
         if ($this->getIsCreateTransaction()) {
           return pht(
             '%s created this object in space %s.',
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($new));
         } else {
           return pht(
             '%s shifted this object from the %s space to the %s space.',
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($old),
             $this->renderHandleLink($new));
         }
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         $add = array_diff($new, $old);
         $rem = array_diff($old, $new);
 
         if ($add && $rem) {
           return pht(
             '%s edited subscriber(s), added %d: %s; removed %d: %s.',
             $this->renderHandleLink($author_phid),
             count($add),
             $this->renderSubscriberList($add, 'add'),
             count($rem),
             $this->renderSubscriberList($rem, 'rem'));
         } else if ($add) {
           if ($this->isSelfSubscription()) {
             return pht(
               '%s subscribed.',
               $this->renderHandleLink($author_phid));
           }
           return pht(
             '%s added %d subscriber(s): %s.',
             $this->renderHandleLink($author_phid),
             count($add),
             $this->renderSubscriberList($add, 'add'));
         } else if ($rem) {
           if ($this->isSelfSubscription()) {
             return pht(
               '%s unsubscribed.',
               $this->renderHandleLink($author_phid));
           }
           return pht(
             '%s removed %d subscriber(s): %s.',
             $this->renderHandleLink($author_phid),
             count($rem),
             $this->renderSubscriberList($rem, 'rem'));
         } else {
           // This is used when rendering previews, before the user actually
           // selects any CCs.
           return pht(
             '%s updated subscribers...',
             $this->renderHandleLink($author_phid));
         }
         break;
       case PhabricatorTransactions::TYPE_FILE:
         $add = array_diff_key($new, $old);
         $add = array_keys($add);
 
         $rem = array_diff_key($old, $new);
         $rem = array_keys($rem);
 
         $mod = array();
         foreach ($old + $new as $key => $ignored) {
           if (!isset($old[$key])) {
             continue;
           }
 
           if (!isset($new[$key])) {
             continue;
           }
 
           if ($old[$key] === $new[$key]) {
             continue;
           }
 
           $mod[] = $key;
         }
 
         // Specialize the specific case of only modifying files and upgrading
         // references to attachments. This is accessible via the UI and can
         // be shown more clearly than the generic default transaction shows
         // it.
 
         $mode_reference = PhabricatorFileAttachment::MODE_REFERENCE;
         $mode_attach = PhabricatorFileAttachment::MODE_ATTACH;
 
         $is_refattach = false;
         if ($mod && !$add && !$rem) {
           $all_refattach = true;
           foreach ($mod as $phid) {
             if (idx($old, $phid) !== $mode_reference) {
               $all_refattach = false;
               break;
             }
             if (idx($new, $phid) !== $mode_attach) {
               $all_refattach = false;
               break;
             }
           }
           $is_refattach = $all_refattach;
         }
 
         if ($is_refattach) {
           return pht(
             '%s attached %s referenced file(s): %s.',
             $this->renderHandleLink($author_phid),
             phutil_count($mod),
             $this->renderHandleList($mod));
         } else if ($add && $rem && $mod) {
           return pht(
             '%s updated %s attached file(s), added %s: %s; removed %s: %s; '.
             'modified %s: %s.',
             $this->renderHandleLink($author_phid),
             new PhutilNumber(count($add) + count($rem)),
             phutil_count($add),
             $this->renderHandleList($add),
             phutil_count($rem),
             $this->renderHandleList($rem),
             phutil_count($mod),
             $this->renderHandleList($mod));
         } else if ($add && $rem) {
           return pht(
             '%s updated %s attached file(s), added %s: %s; removed %s: %s.',
             $this->renderHandleLink($author_phid),
             new PhutilNumber(count($add) + count($rem)),
             phutil_count($add),
             $this->renderHandleList($add),
             phutil_count($rem),
             $this->renderHandleList($rem));
         } else if ($add && $mod) {
           return pht(
             '%s updated %s attached file(s), added %s: %s; modified %s: %s.',
             $this->renderHandleLink($author_phid),
             new PhutilNumber(count($add) + count($mod)),
             phutil_count($add),
             $this->renderHandleList($add),
             phutil_count($mod),
             $this->renderHandleList($mod));
         } else if ($rem && $mod) {
           return pht(
             '%s updated %s attached file(s), removed %s: %s; modified %s: %s.',
             $this->renderHandleLink($author_phid),
             new PhutilNumber(count($rem) + count($mod)),
             phutil_count($rem),
             $this->renderHandleList($rem),
             phutil_count($mod),
             $this->renderHandleList($mod));
         } else if ($add) {
           return pht(
             '%s attached %s file(s): %s.',
             $this->renderHandleLink($author_phid),
             phutil_count($add),
             $this->renderHandleList($add));
         } else if ($rem) {
           return pht(
             '%s removed %s attached file(s): %s.',
             $this->renderHandleLink($author_phid),
             phutil_count($rem),
             $this->renderHandleList($rem));
         } else if ($mod) {
           return pht(
             '%s modified %s attached file(s): %s.',
             $this->renderHandleLink($author_phid),
             phutil_count($mod),
             $this->renderHandleList($mod));
         } else {
           return pht(
             '%s attached files...',
             $this->renderHandleLink($author_phid));
         }
 
         break;
       case PhabricatorTransactions::TYPE_EDGE:
         $record = PhabricatorEdgeChangeRecord::newFromTransaction($this);
         $add = $record->getAddedPHIDs();
         $rem = $record->getRemovedPHIDs();
 
         $type = $this->getMetadata('edge:type');
         $type = head($type);
 
         try {
           $type_obj = PhabricatorEdgeType::getByConstant($type);
         } catch (Exception $ex) {
           // Recover somewhat gracefully from edge transactions which
           // we don't have the classes for.
           return pht(
             '%s edited an edge.',
             $this->renderHandleLink($author_phid));
         }
 
         if ($add && $rem) {
           return $type_obj->getTransactionEditString(
             $this->renderHandleLink($author_phid),
             new PhutilNumber(count($add) + count($rem)),
             phutil_count($add),
             $this->renderHandleList($add),
             phutil_count($rem),
             $this->renderHandleList($rem));
         } else if ($add) {
           return $type_obj->getTransactionAddString(
             $this->renderHandleLink($author_phid),
             phutil_count($add),
             $this->renderHandleList($add));
         } else if ($rem) {
           return $type_obj->getTransactionRemoveString(
             $this->renderHandleLink($author_phid),
             phutil_count($rem),
             $this->renderHandleList($rem));
         } else {
           return $type_obj->getTransactionPreviewString(
             $this->renderHandleLink($author_phid));
         }
 
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->getApplicationTransactionTitle($this);
         } else {
           $developer_mode = 'phabricator.developer-mode';
           $is_developer = PhabricatorEnv::getEnvConfig($developer_mode);
           if ($is_developer) {
             return pht(
               '%s edited a custom field (with key "%s").',
               $this->renderHandleLink($author_phid),
               $this->getMetadata('customfield:key'));
           } else {
             return pht(
               '%s edited a custom field.',
               $this->renderHandleLink($author_phid));
           }
         }
 
       case PhabricatorTransactions::TYPE_TOKEN:
         if ($old && $new) {
           return pht(
             '%s updated a token.',
             $this->renderHandleLink($author_phid));
         } else if ($old) {
           return pht(
             '%s rescinded a token.',
             $this->renderHandleLink($author_phid));
         } else {
           return pht(
             '%s awarded a token.',
             $this->renderHandleLink($author_phid));
         }
 
       case PhabricatorTransactions::TYPE_INLINESTATE:
         list($done, $undone) = $this->getInterestingInlineStateChangeCounts();
         if ($done && $undone) {
           return pht(
             '%s marked %s inline comment(s) as done and %s inline comment(s) '.
             'as not done.',
             $this->renderHandleLink($author_phid),
             new PhutilNumber($done),
             new PhutilNumber($undone));
         } else if ($done) {
           return pht(
             '%s marked %s inline comment(s) as done.',
             $this->renderHandleLink($author_phid),
             new PhutilNumber($done));
         } else {
           return pht(
             '%s marked %s inline comment(s) as not done.',
             $this->renderHandleLink($author_phid),
             new PhutilNumber($undone));
         }
         break;
 
       case PhabricatorTransactions::TYPE_COLUMNS:
         $moves = $this->getInterestingMoves($new);
         if (count($moves) == 1) {
           $move = head($moves);
           $from_columns = $move['fromColumnPHIDs'];
           $to_column = $move['columnPHID'];
           $board_phid = $move['boardPHID'];
           if (count($from_columns) == 1) {
             return pht(
               '%s moved this task from %s to %s on the %s board.',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink(head($from_columns)),
               $this->renderHandleLink($to_column),
               $this->renderHandleLink($board_phid));
           } else {
             return pht(
               '%s moved this task to %s on the %s board.',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink($to_column),
               $this->renderHandleLink($board_phid));
           }
         } else {
           $fragments = array();
           foreach ($moves as $move) {
             $to_column = $move['columnPHID'];
             $board_phid = $move['boardPHID'];
             $fragments[] = pht(
               '%s (%s)',
               $this->renderHandleLink($board_phid),
               $this->renderHandleLink($to_column));
           }
 
           return pht(
             '%s moved this task on %s board(s): %s.',
             $this->renderHandleLink($author_phid),
             phutil_count($moves),
             phutil_implode_html(', ', $fragments));
         }
         break;
 
 
       case PhabricatorTransactions::TYPE_MFA:
         return pht(
           '%s signed these changes with MFA.',
           $this->renderHandleLink($author_phid));
 
       default:
         // In developer mode, provide a better hint here about which string
         // we're missing.
         $developer_mode = 'phabricator.developer-mode';
         $is_developer = PhabricatorEnv::getEnvConfig($developer_mode);
         if ($is_developer) {
           return pht(
             '%s edited this object (transaction type "%s").',
             $this->renderHandleLink($author_phid),
             $this->getTransactionType());
         } else {
           return pht(
             '%s edited this %s.',
             $this->renderHandleLink($author_phid),
             $this->getApplicationObjectTypeName());
         }
     }
   }
 
   public function getTitleForFeed() {
     $author_phid = $this->getAuthorPHID();
     $object_phid = $this->getObjectPHID();
 
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CREATE:
         return pht(
           '%s created %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_COMMENT:
         return pht(
           '%s added a comment to %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         return pht(
           '%s changed the visibility for %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         return pht(
           '%s changed the edit policy for %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         return pht(
           '%s changed the join policy for %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
         return pht(
           '%s changed the interact policy for %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return pht(
           '%s updated subscribers of %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_SPACE:
         if ($this->getIsCreateTransaction()) {
           return pht(
             '%s created %s in the %s space.',
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid),
             $this->renderHandleLink($new));
         } else {
           return pht(
             '%s shifted %s from the %s space to the %s space.',
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid),
             $this->renderHandleLink($old),
             $this->renderHandleLink($new));
         }
       case PhabricatorTransactions::TYPE_EDGE:
         $record = PhabricatorEdgeChangeRecord::newFromTransaction($this);
         $add = $record->getAddedPHIDs();
         $rem = $record->getRemovedPHIDs();
 
         $type = $this->getMetadata('edge:type');
         $type = head($type);
 
         $type_obj = PhabricatorEdgeType::getByConstant($type);
 
         if ($add && $rem) {
           return $type_obj->getFeedEditString(
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid),
             new PhutilNumber(count($add) + count($rem)),
             phutil_count($add),
             $this->renderHandleList($add),
             phutil_count($rem),
             $this->renderHandleList($rem));
         } else if ($add) {
           return $type_obj->getFeedAddString(
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid),
             phutil_count($add),
             $this->renderHandleList($add));
         } else if ($rem) {
           return $type_obj->getFeedRemoveString(
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid),
             phutil_count($rem),
             $this->renderHandleList($rem));
         } else {
           return pht(
             '%s edited edge metadata for %s.',
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid));
         }
 
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->getApplicationTransactionTitleForFeed($this);
         } else {
           return pht(
             '%s edited a custom field on %s.',
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid));
         }
 
       case PhabricatorTransactions::TYPE_COLUMNS:
         $moves = $this->getInterestingMoves($new);
         if (count($moves) == 1) {
           $move = head($moves);
           $from_columns = $move['fromColumnPHIDs'];
           $to_column = $move['columnPHID'];
           $board_phid = $move['boardPHID'];
           if (count($from_columns) == 1) {
             return pht(
               '%s moved %s from %s to %s on the %s board.',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink($object_phid),
               $this->renderHandleLink(head($from_columns)),
               $this->renderHandleLink($to_column),
               $this->renderHandleLink($board_phid));
           } else {
             return pht(
               '%s moved %s to %s on the %s board.',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink($object_phid),
               $this->renderHandleLink($to_column),
               $this->renderHandleLink($board_phid));
           }
         } else {
           $fragments = array();
           foreach ($moves as $move) {
             $to_column = $move['columnPHID'];
             $board_phid = $move['boardPHID'];
             $fragments[] = pht(
               '%s (%s)',
               $this->renderHandleLink($board_phid),
               $this->renderHandleLink($to_column));
           }
 
           return pht(
             '%s moved %s on %s board(s): %s.',
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid),
             phutil_count($moves),
             phutil_implode_html(', ', $fragments));
         }
         break;
 
       case PhabricatorTransactions::TYPE_MFA:
         return null;
 
     }
 
     return $this->getTitle();
   }
 
   public function getMarkupFieldsForFeed(PhabricatorFeedStory $story) {
     $fields = array();
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         $text = $this->getComment()->getContent();
         if (strlen($text)) {
           $fields[] = 'comment/'.$this->getID();
         }
         break;
     }
 
     return $fields;
   }
 
   public function getMarkupTextForFeed(PhabricatorFeedStory $story, $field) {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         $text = $this->getComment()->getContent();
         return PhabricatorMarkupEngine::summarize($text);
     }
 
     return null;
   }
 
   public function getBodyForFeed(PhabricatorFeedStory $story) {
     $remarkup = $this->getRemarkupBodyForFeed($story);
     if ($remarkup !== null) {
       $remarkup = PhabricatorMarkupEngine::summarize($remarkup);
       return new PHUIRemarkupView($this->viewer, $remarkup);
     }
 
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     $body = null;
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         $text = $this->getComment()->getContent();
         if (strlen($text)) {
           $body = $story->getMarkupFieldOutput('comment/'.$this->getID());
         }
         break;
     }
 
     return $body;
   }
 
   public function getRemarkupBodyForFeed(PhabricatorFeedStory $story) {
     return null;
   }
 
   public function getActionStrength() {
     if ($this->isInlineCommentTransaction()) {
       return 25;
     }
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return 50;
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         if ($this->isSelfSubscription()) {
           // Make this weaker than TYPE_COMMENT.
           return 25;
         }
 
         // In other cases, subscriptions are more interesting than comments
         // (which are shown anyway) but less interesting than any other type of
         // transaction.
         return 75;
       case PhabricatorTransactions::TYPE_MFA:
         // We want MFA signatures to render at the top of transaction groups,
         // on top of the things they signed.
         return 1000;
     }
 
     return 100;
   }
 
   /**
    * Whether the transaction concerns a comment (e.g. add, edit, remove)
    * @return bool True if the transaction concerns a comment
    */
   public function isCommentTransaction() {
     if ($this->hasComment()) {
       return true;
     }
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return true;
     }
 
     return false;
   }
 
   public function isInlineCommentTransaction() {
     return false;
   }
 
   public function getActionName() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return pht('Commented On');
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
       case PhabricatorTransactions::TYPE_INTERACT_POLICY:
         return pht('Changed Policy');
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return pht('Changed Subscribers');
       case PhabricatorTransactions::TYPE_CREATE:
         return pht('Created');
       default:
         return pht('Updated');
     }
   }
 
   public function getMailTags() {
     return array();
   }
 
   public function hasChangeDetails() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_FILE:
         return true;
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->getApplicationTransactionHasChangeDetails($this);
         }
         break;
     }
     return false;
   }
 
   public function hasChangeDetailsForMail() {
     return $this->hasChangeDetails();
   }
 
   public function renderChangeDetailsForMail(PhabricatorUser $viewer) {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_FILE:
         return false;
     }
 
     $view = $this->renderChangeDetails($viewer);
     if ($view instanceof PhabricatorApplicationTransactionTextDiffDetailView) {
       return $view->renderForMail();
     }
     return null;
   }
 
   public function renderChangeDetails(PhabricatorUser $viewer) {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_FILE:
         return $this->newFileTransactionChangeDetails($viewer);
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->getApplicationTransactionChangeDetails($this, $viewer);
         }
         break;
     }
 
     return $this->renderTextCorpusChangeDetails(
       $viewer,
       $this->getOldValue(),
       $this->getNewValue());
   }
 
   public function renderTextCorpusChangeDetails(
     PhabricatorUser $viewer,
     $old,
     $new) {
     return id(new PhabricatorApplicationTransactionTextDiffDetailView())
       ->setUser($viewer)
       ->setOldText($old)
       ->setNewText($new);
   }
 
   public function attachTransactionGroup(array $group) {
     assert_instances_of($group, __CLASS__);
     $this->transactionGroup = $group;
     return $this;
   }
 
   public function getTransactionGroup() {
     return $this->transactionGroup;
   }
 
   /**
    * Should this transaction be visually grouped with an existing transaction
    * group?
    *
-   * @param list<PhabricatorApplicationTransaction> List of transactions.
+   * @param list<PhabricatorApplicationTransaction> $group List of transactions.
    * @return bool True to display in a group with the other transactions.
    */
   public function shouldDisplayGroupWith(array $group) {
     $this_source = null;
     if ($this->getContentSource()) {
       $this_source = $this->getContentSource()->getSource();
     }
 
     $type_mfa = PhabricatorTransactions::TYPE_MFA;
 
     foreach ($group as $xaction) {
       // Don't group transactions by different authors.
       if ($xaction->getAuthorPHID() != $this->getAuthorPHID()) {
         return false;
       }
 
       // Don't group transactions for different objects.
       if ($xaction->getObjectPHID() != $this->getObjectPHID()) {
         return false;
       }
 
       // Don't group anything into a group which already has a comment.
       if ($xaction->isCommentTransaction()) {
         return false;
       }
 
       // Don't group transactions from different content sources.
       $other_source = null;
       if ($xaction->getContentSource()) {
         $other_source = $xaction->getContentSource()->getSource();
       }
 
       if ($other_source != $this_source) {
         return false;
       }
 
       // Don't group transactions which happened more than 2 minutes apart.
       $apart = abs($xaction->getDateCreated() - $this->getDateCreated());
       if ($apart > (60 * 2)) {
         return false;
       }
 
       // Don't group silent and nonsilent transactions together.
       $is_silent = $this->getIsSilentTransaction();
       if ($is_silent != $xaction->getIsSilentTransaction()) {
         return false;
       }
 
       // Don't group MFA and non-MFA transactions together.
       $is_mfa = $this->getIsMFATransaction();
       if ($is_mfa != $xaction->getIsMFATransaction()) {
         return false;
       }
 
       // Don't group two "Sign with MFA" transactions together.
       if ($this->getTransactionType() === $type_mfa) {
         if ($xaction->getTransactionType() === $type_mfa) {
           return false;
         }
       }
 
       // Don't group lock override and non-override transactions together.
       $is_override = $this->getIsLockOverrideTransaction();
       if ($is_override != $xaction->getIsLockOverrideTransaction()) {
         return false;
       }
     }
 
     return true;
   }
 
   public function renderExtraInformationLink() {
     $herald_xscript_id = $this->getMetadataValue('herald:transcriptID');
 
     if ($herald_xscript_id) {
       return phutil_tag(
         'a',
         array(
           'href' => '/herald/transcript/'.$herald_xscript_id.'/',
         ),
         pht('View Herald Transcript'));
     }
 
     return null;
   }
 
   public function renderAsTextForDoorkeeper(
     DoorkeeperFeedStoryPublisher $publisher,
     PhabricatorFeedStory $story,
     array $xactions) {
 
     $text = array();
     $body = array();
 
     foreach ($xactions as $xaction) {
       $xaction_body = $xaction->getBodyForMail();
       if ($xaction_body !== null) {
         $body[] = $xaction_body;
       }
 
       if ($xaction->shouldHideForMail($xactions)) {
         continue;
       }
 
       $old_target = $xaction->getRenderingTarget();
       $new_target = self::TARGET_TEXT;
       $xaction->setRenderingTarget($new_target);
 
       if ($publisher->getRenderWithImpliedContext()) {
         $text[] = $xaction->getTitle();
       } else {
         $text[] = $xaction->getTitleForFeed();
       }
 
       $xaction->setRenderingTarget($old_target);
     }
 
     $text = implode("\n", $text);
     $body = implode("\n\n", $body);
 
     return rtrim($text."\n\n".$body);
   }
 
   /**
    * Test if this transaction is just a user subscribing or unsubscribing
    * themselves.
    */
   private function isSelfSubscription() {
     $type = $this->getTransactionType();
     if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) {
       return false;
     }
 
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     $add = array_diff($old, $new);
     $rem = array_diff($new, $old);
 
     if ((count($add) + count($rem)) != 1) {
       // More than one user affected.
       return false;
     }
 
     $affected_phid = head(array_merge($add, $rem));
     if ($affected_phid != $this->getAuthorPHID()) {
       // Affected user is someone else.
       return false;
     }
 
     return true;
   }
 
   private function isApplicationAuthor() {
     $author_phid = $this->getAuthorPHID();
     $author_type = phid_get_type($author_phid);
     $application_type = PhabricatorApplicationApplicationPHIDType::TYPECONST;
     return ($author_type == $application_type);
   }
 
 
   private function getInterestingMoves(array $moves) {
     // Remove moves which only shift the position of a task within a column.
     foreach ($moves as $key => $move) {
       $from_phids = array_fuse($move['fromColumnPHIDs']);
       if (isset($from_phids[$move['columnPHID']])) {
         unset($moves[$key]);
       }
     }
 
     return $moves;
   }
 
   private function getInterestingInlineStateChangeCounts() {
     // See PHI995. Newer inline state transactions have additional details
     // which we use to tailor the rendering behavior. These details are not
     // present on older transactions.
     $details = $this->getMetadataValue('inline.details', array());
 
     $new = $this->getNewValue();
 
     $done = 0;
     $undone = 0;
     foreach ($new as $phid => $state) {
       $is_done = ($state == PhabricatorInlineComment::STATE_DONE);
 
       // See PHI995. If you're marking your own inline comments as "Done",
       // don't count them when rendering a timeline story. In the case where
       // you're only affecting your own comments, this will hide the
       // "alice marked X comments as done" story entirely.
 
       // Usually, this happens when you pre-mark inlines as "done" and submit
       // them yourself. We'll still generate an "alice added inline comments"
       // story (in most cases/contexts), but the state change story is largely
       // just clutter and slightly confusing/misleading.
 
       $inline_details = idx($details, $phid, array());
       $inline_author_phid = idx($inline_details, 'authorPHID');
       if ($inline_author_phid) {
         if ($inline_author_phid == $this->getAuthorPHID()) {
           if ($is_done) {
             continue;
           }
         }
       }
 
       if ($is_done) {
         $done++;
       } else {
         $undone++;
       }
     }
 
     return array($done, $undone);
   }
 
   public function newGlobalSortVector() {
     return id(new PhutilSortVector())
       ->addInt(-$this->getDateCreated())
       ->addString($this->getPHID());
   }
 
   public function newActionStrengthSortVector() {
     return id(new PhutilSortVector())
       ->addInt(-$this->getActionStrength());
   }
 
   private function newFileTransactionChangeDetails(PhabricatorUser $viewer) {
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     $phids = array_keys($old + $new);
     $handles = $viewer->loadHandles($phids);
 
     $names = array(
       PhabricatorFileAttachment::MODE_REFERENCE => pht('Referenced'),
       PhabricatorFileAttachment::MODE_ATTACH => pht('Attached'),
     );
 
     $rows = array();
     foreach ($old + $new as $phid => $ignored) {
       $handle = $handles[$phid];
 
       $old_mode = idx($old, $phid);
       $new_mode = idx($new, $phid);
 
       if ($old_mode === null) {
         $old_name = pht('None');
       } else if (isset($names[$old_mode])) {
         $old_name = $names[$old_mode];
       } else {
         $old_name = pht('Unknown ("%s")', $old_mode);
       }
 
       if ($new_mode === null) {
         $new_name = pht('Detached');
       } else if (isset($names[$new_mode])) {
         $new_name = $names[$new_mode];
       } else {
         $new_name = pht('Unknown ("%s")', $new_mode);
       }
 
       $rows[] = array(
         $handle->renderLink(),
         $old_name,
         $new_name,
       );
     }
 
     $table = id(new AphrontTableView($rows))
       ->setHeaders(
         array(
           pht('File'),
           pht('Old Mode'),
           pht('New Mode'),
         ))
       ->setColumnClasses(
         array(
           'pri',
         ));
 
     return id(new PHUIBoxView())
       ->addMargin(PHUI::MARGIN_SMALL)
       ->appendChild($table);
   }
 
 
 
 /* -(  PhabricatorPolicyInterface Implementation  )-------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         return $this->getViewPolicy();
       case PhabricatorPolicyCapability::CAN_EDIT:
         return $this->getEditPolicy();
     }
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return ($viewer->getPHID() == $this->getAuthorPHID());
   }
 
   public function describeAutomaticCapability($capability) {
     return pht(
       'Transactions are visible to users that can see the object which was '.
       'acted upon. Some transactions - in particular, comments - are '.
       'editable by the transaction author.');
   }
 
   public function getModularType() {
     return null;
   }
 
   public function setForceNotifyPHIDs(array $phids) {
     $this->setMetadataValue('notify.force', $phids);
     return $this;
   }
 
   public function getForceNotifyPHIDs() {
     return $this->getMetadataValue('notify.force', array());
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
 
     $this->openTransaction();
       $comment_template = $this->getApplicationTransactionCommentObject();
 
       if ($comment_template) {
         $comments = $comment_template->loadAllWhere(
           'transactionPHID = %s',
           $this->getPHID());
         foreach ($comments as $comment) {
           $engine->destroyObject($comment);
         }
       }
 
       $this->delete();
     $this->saveTransaction();
   }
 
 }
diff --git a/src/applications/transactions/storage/PhabricatorModularTransactionType.php b/src/applications/transactions/storage/PhabricatorModularTransactionType.php
index e2f0a02239..1d449ab868 100644
--- a/src/applications/transactions/storage/PhabricatorModularTransactionType.php
+++ b/src/applications/transactions/storage/PhabricatorModularTransactionType.php
@@ -1,513 +1,514 @@
 <?php
 
 abstract class PhabricatorModularTransactionType
   extends Phobject {
 
   private $storage;
   private $viewer;
   private $editor;
 
   final public function getTransactionTypeConstant() {
     return $this->getPhobjectClassConstant('TRANSACTIONTYPE');
   }
 
   public function generateOldValue($object) {
     throw new PhutilMethodNotImplementedException();
   }
 
   public function generateNewValue($object, $value) {
     return $value;
   }
 
   public function validateTransactions($object, array $xactions) {
     return array();
   }
 
   public function applyInternalEffects($object, $value) {
     return;
   }
 
   public function applyExternalEffects($object, $value) {
     return;
   }
 
   public function didCommitTransaction($object, $value) {
     return;
   }
 
   public function getTransactionHasEffect($object, $old, $new) {
     return ($old !== $new);
   }
 
   public function extractFilePHIDs($object, $value) {
     return array();
   }
 
   public function shouldHide() {
     return false;
   }
 
   public function shouldHideForFeed() {
     return false;
   }
 
   public function shouldHideForMail() {
     return false;
   }
 
   public function shouldHideForNotifications() {
     return null;
   }
 
   public function getIcon() {
     return null;
   }
 
   public function getTitle() {
     return null;
   }
 
   public function getTitleForFeed() {
     return null;
   }
 
   public function getActionName() {
     return null;
   }
 
   public function getActionStrength() {
     return null;
   }
 
   public function getColor() {
     return null;
   }
 
   public function hasChangeDetailView() {
     return false;
   }
 
   public function newChangeDetailView() {
     return null;
   }
 
   public function getMailDiffSectionHeader() {
     return pht('EDIT DETAILS');
   }
 
   public function newRemarkupChanges() {
     return array();
   }
 
   public function mergeTransactions(
     $object,
     PhabricatorApplicationTransaction $u,
     PhabricatorApplicationTransaction $v) {
     return null;
   }
 
   final public function setStorage(
     PhabricatorApplicationTransaction $xaction) {
     $this->storage = $xaction;
     return $this;
   }
 
   private function getStorage() {
     return $this->storage;
   }
 
   final public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   final protected function getViewer() {
     return $this->viewer;
   }
 
   final public function getActor() {
     return $this->getEditor()->getActor();
   }
 
   final public function getActingAsPHID() {
     return $this->getEditor()->getActingAsPHID();
   }
 
   final public function setEditor(
     PhabricatorApplicationTransactionEditor $editor) {
     $this->editor = $editor;
     return $this;
   }
 
   final protected function getEditor() {
     if (!$this->editor) {
       throw new PhutilInvalidStateException('setEditor');
     }
     return $this->editor;
   }
 
   final protected function hasEditor() {
     return (bool)$this->editor;
   }
 
   final protected function getAuthorPHID() {
     return $this->getStorage()->getAuthorPHID();
   }
 
   final protected function getObjectPHID() {
     return $this->getStorage()->getObjectPHID();
   }
 
   final protected function getObject() {
     return $this->getStorage()->getObject();
   }
 
   final protected function getOldValue() {
     return $this->getStorage()->getOldValue();
   }
 
   final protected function getNewValue() {
     return $this->getStorage()->getNewValue();
   }
 
   final protected function renderAuthor() {
     $author_phid = $this->getAuthorPHID();
     return $this->getStorage()->renderHandleLink($author_phid);
   }
 
   final protected function renderObject() {
     $object_phid = $this->getObjectPHID();
     return $this->getStorage()->renderHandleLink($object_phid);
   }
 
   final protected function renderHandle($phid) {
     $viewer = $this->getViewer();
     $display = $viewer->renderHandle($phid);
 
     if ($this->isTextMode()) {
       $display->setAsText(true);
     }
 
     return $display;
   }
 
   final protected function renderOldHandle() {
     return $this->renderHandle($this->getOldValue());
   }
 
   final protected function renderNewHandle() {
     return $this->renderHandle($this->getNewValue());
   }
 
   final protected function renderOldPolicy() {
     return $this->renderPolicy($this->getOldValue(), 'old');
   }
 
   final protected function renderNewPolicy() {
     return $this->renderPolicy($this->getNewValue(), 'new');
   }
 
   final protected function renderPolicy($phid, $mode) {
     $viewer = $this->getViewer();
     $handles = $viewer->loadHandles(array($phid));
 
     $policy = PhabricatorPolicy::newFromPolicyAndHandle(
       $phid,
       $handles[$phid]);
 
     $ref = $policy->newRef($viewer);
 
     if ($this->isTextMode()) {
       $name = $ref->getPolicyDisplayName();
     } else {
       $storage = $this->getStorage();
       $name = $ref->newTransactionLink($mode, $storage);
     }
 
     return $this->renderValue($name);
   }
 
   final protected function renderHandleList(array $phids) {
     $viewer = $this->getViewer();
     $display = $viewer->renderHandleList($phids)
       ->setAsInline(true);
 
     if ($this->isTextMode()) {
       $display->setAsText(true);
     }
 
     return $display;
   }
 
   final protected function renderValue($value) {
     if ($this->isTextMode()) {
       return sprintf('"%s"', $value);
     }
 
     return phutil_tag(
       'span',
       array(
         'class' => 'phui-timeline-value',
       ),
       $value);
   }
 
   final protected function renderValueList(array $values) {
     $result = array();
     foreach ($values as $value) {
       $result[] = $this->renderValue($value);
     }
 
     if ($this->isTextMode()) {
       return implode(', ', $result);
     }
 
     return phutil_implode_html(', ', $result);
   }
 
   final protected function renderOldValue() {
     return $this->renderValue($this->getOldValue());
   }
 
   final protected function renderNewValue() {
     return $this->renderValue($this->getNewValue());
   }
 
   final protected function renderDate($epoch) {
     $viewer = $this->getViewer();
 
     // We accept either epoch timestamps or dictionaries describing a
     // PhutilCalendarDateTime.
     if (is_array($epoch)) {
       $datetime = PhutilCalendarAbsoluteDateTime::newFromDictionary($epoch)
         ->setViewerTimezone($viewer->getTimezoneIdentifier());
 
       $all_day = $datetime->getIsAllDay();
 
       $epoch = $datetime->getEpoch();
     } else {
       $all_day = false;
     }
 
     if ($all_day) {
       $display = phabricator_date($epoch, $viewer);
     } else if ($this->isRenderingTargetExternal()) {
       // When rendering to text, we explicitly render the offset from UTC to
       // provide context to the date: the mail may be generating with the
       // server's settings, or the user may later refer back to it after
       // changing timezones.
 
       $display = phabricator_datetimezone($epoch, $viewer);
     } else {
       $display = phabricator_datetime($epoch, $viewer);
     }
 
     return $this->renderValue($display);
   }
 
   final protected function renderOldDate() {
     return $this->renderDate($this->getOldValue());
   }
 
   final protected function renderNewDate() {
     return $this->renderDate($this->getNewValue());
   }
 
   final protected function newError($title, $message, $xaction = null) {
     return new PhabricatorApplicationTransactionValidationError(
       $this->getTransactionTypeConstant(),
       $title,
       $message,
       $xaction);
   }
 
   final protected function newRequiredError($message, $xaction = null) {
     return $this->newError(pht('Required'), $message, $xaction)
       ->setIsMissingFieldError(true);
   }
 
   final protected function newInvalidError($message, $xaction = null) {
     return $this->newError(pht('Invalid'), $message, $xaction);
   }
 
   final protected function isNewObject() {
     return $this->getEditor()->getIsNewObject();
   }
 
   /**
    * Check whenever a new transaction's value is considered an "empty text"
    * @param mixed $value    A string, null, an integer...
    * @param array $xactions Transactions
    */
   final protected function isEmptyTextTransaction($value, array $xactions) {
     foreach ($xactions as $xaction) {
       $value = $xaction->getNewValue();
     }
 
     // The $value can be a lot of stuff, null, string, integer and maybe more.
     // We cast to string to answer the question "Is this string empty?".
     // Note: Don't use phutil_nonempty_stringlike() since it was not designed
     //       for integers.
     // Note: Don't use phutil_nonempty_scalar() since very probably we could
     //       receive a boolean, causing exceptions.
     //       https://we.phorge.it/T15239
     $value_clean = phutil_string_cast($value);
 
     // We made our lives easier and we don't need strlen(something)
     // and we should not.
     return $value_clean === '';
   }
 
   /**
    * When rendering to external targets (Email/Asana/etc), we need to include
    * more information that users can't obtain later.
    */
   final protected function isRenderingTargetExternal() {
     // Right now, this is our best proxy for this:
     return $this->isTextMode();
     // "TARGET_TEXT" means "EMail" and "TARGET_HTML" means "Web".
   }
 
   final protected function isTextMode() {
     $target = $this->getStorage()->getRenderingTarget();
     return ($target == PhabricatorApplicationTransaction::TARGET_TEXT);
   }
 
   final protected function newRemarkupChange() {
     return id(new PhabricatorTransactionRemarkupChange())
       ->setTransaction($this->getStorage());
   }
 
   final protected function isCreateTransaction() {
     return $this->getStorage()->getIsCreateTransaction();
   }
 
   final protected function getPHIDList(array $old, array $new) {
     $editor = $this->getEditor();
 
     return $editor->getPHIDList($old, $new);
   }
 
   public function getMetadataValue($key, $default = null) {
     return $this->getStorage()->getMetadataValue($key, $default);
   }
 
   public function loadTransactionTypeConduitData(array $xactions) {
     return null;
   }
 
   public function getTransactionTypeForConduit($xaction) {
     return null;
   }
 
   public function getFieldValuesForConduit($xaction, $data) {
     return array();
   }
 
   protected function requireApplicationCapability($capability) {
     $application_class = $this->getEditor()->getEditorApplicationClass();
     $application = newv($application_class, array());
 
     PhabricatorPolicyFilter::requireCapability(
       $this->getActor(),
       $application,
       $capability);
   }
 
   /**
    * Get a list of capabilities the actor must have on the object to apply
    * a transaction to it.
    *
    * Usually, you should use this to reduce capability requirements when a
    * transaction (like leaving a Conpherence thread) can be applied without
    * having edit permission on the object. You can override this method to
    * remove the CAN_EDIT requirement, or to replace it with a different
    * requirement.
    *
    * If you are increasing capability requirements and need to add an
    * additional capability or policy requirement above and beyond CAN_EDIT, it
    * is usually better implemented as a validation check.
    *
-   * @param object Object being edited.
-   * @param PhabricatorApplicationTransaction Transaction being applied.
+   * @param object $object Object being edited.
+   * @param PhabricatorApplicationTransaction $xaction Transaction being
+   *    applied.
    * @return null|const|list<const> A capability constant (or list of
    *    capability constants) which the actor must have on the object. You can
    *    return `null` as a shorthand for "no capabilities are required".
    */
   public function getRequiredCapabilities(
     $object,
     PhabricatorApplicationTransaction $xaction) {
     return PhabricatorPolicyCapability::CAN_EDIT;
   }
 
   public function shouldTryMFA(
     $object,
     PhabricatorApplicationTransaction $xaction) {
     return false;
   }
 
   // NOTE: See T12921. These APIs are somewhat aspirational. For now, all of
   // these use "TARGET_TEXT" (even the HTML methods!) and the body methods
   // actually return Remarkup, not text or HTML.
 
   final public function getTitleForTextMail() {
     return $this->getTitleForMailWithRenderingTarget(
       PhabricatorApplicationTransaction::TARGET_TEXT);
   }
 
   final public function getTitleForHTMLMail() {
     return $this->getTitleForMailWithRenderingTarget(
       PhabricatorApplicationTransaction::TARGET_TEXT);
   }
 
   final public function getBodyForTextMail() {
     return $this->getBodyForMailWithRenderingTarget(
       PhabricatorApplicationTransaction::TARGET_TEXT);
   }
 
   final public function getBodyForHTMLMail() {
     return $this->getBodyForMailWithRenderingTarget(
       PhabricatorApplicationTransaction::TARGET_TEXT);
   }
 
   private function getTitleForMailWithRenderingTarget($target) {
     $storage = $this->getStorage();
 
     $old_target = $storage->getRenderingTarget();
     try {
       $storage->setRenderingTarget($target);
       $result = $this->getTitleForMail();
     } catch (Exception $ex) {
       $storage->setRenderingTarget($old_target);
       throw $ex;
     }
     $storage->setRenderingTarget($old_target);
 
     return $result;
   }
 
   private function getBodyForMailWithRenderingTarget($target) {
     $storage = $this->getStorage();
 
     $old_target = $storage->getRenderingTarget();
     try {
       $storage->setRenderingTarget($target);
       $result = $this->getBodyForMail();
     } catch (Exception $ex) {
       $storage->setRenderingTarget($old_target);
       throw $ex;
     }
     $storage->setRenderingTarget($old_target);
 
     return $result;
   }
 
   protected function getTitleForMail() {
     return false;
   }
 
   protected function getBodyForMail() {
     return false;
   }
 
 }
diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php
index 308a8bfe1a..11c94d1933 100644
--- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php
+++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php
@@ -1,670 +1,670 @@
 <?php
 
 /**
  * @task functions Token Functions
  */
 abstract class PhabricatorTypeaheadDatasource extends Phobject {
 
   private $viewer;
   private $query;
   private $rawQuery;
   private $offset;
   private $limit;
   private $parameters = array();
   private $functionStack = array();
   private $isBrowse;
   private $phase = self::PHASE_CONTENT;
 
   const PHASE_PREFIX = 'prefix';
   const PHASE_CONTENT = 'content';
 
   public function setLimit($limit) {
     $this->limit = $limit;
     return $this;
   }
 
   public function getLimit() {
     return $this->limit;
   }
 
   public function setOffset($offset) {
     $this->offset = $offset;
     return $this;
   }
 
   public function getOffset() {
     return $this->offset;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->viewer;
   }
 
   public function setRawQuery($raw_query) {
     $this->rawQuery = $raw_query;
     return $this;
   }
 
   public function getPrefixQuery() {
     return phutil_utf8_strtolower($this->getRawQuery());
   }
 
   public function getRawQuery() {
     return $this->rawQuery;
   }
 
   public function setQuery($query) {
     $this->query = $query;
     return $this;
   }
 
   public function getQuery() {
     return $this->query;
   }
 
   public function setParameters(array $params) {
     $this->parameters = $params;
     return $this;
   }
 
   public function getParameters() {
     return $this->parameters;
   }
 
   public function getParameter($name, $default = null) {
     return idx($this->parameters, $name, $default);
   }
 
   public function setIsBrowse($is_browse) {
     $this->isBrowse = $is_browse;
     return $this;
   }
 
   public function getIsBrowse() {
     return $this->isBrowse;
   }
 
   public function setPhase($phase) {
     $this->phase = $phase;
     return $this;
   }
 
   public function getPhase() {
     return $this->phase;
   }
 
   public function getDatasourceURI() {
     $params = $this->newURIParameters();
     $uri = new PhutilURI('/typeahead/class/'.get_class($this).'/', $params);
     return phutil_string_cast($uri);
   }
 
   public function getBrowseURI() {
     if (!$this->isBrowsable()) {
       return null;
     }
 
     $params = $this->newURIParameters();
     $uri = new PhutilURI('/typeahead/browse/'.get_class($this).'/', $params);
     return phutil_string_cast($uri);
   }
 
   private function newURIParameters() {
     if (!$this->parameters) {
       return array();
     }
 
     $map = array(
       'parameters' => phutil_json_encode($this->parameters),
     );
 
     return $map;
   }
 
   abstract public function getPlaceholderText();
 
   public function getBrowseTitle() {
     return get_class($this);
   }
 
   abstract public function getDatasourceApplicationClass();
   abstract public function loadResults();
 
   protected function loadResultsForPhase($phase, $limit) {
     // By default, sources just load all of their results in every phase and
     // rely on filtering at a higher level to sequence phases correctly.
     $this->setLimit($limit);
     return $this->loadResults();
   }
 
   protected function didLoadResults(array $results) {
     return $results;
   }
 
   public static function tokenizeString($string) {
     $string = phutil_utf8_strtolower($string);
     $string = trim($string);
     if (!strlen($string)) {
       return array();
     }
 
     // NOTE: Splitting on "(" and ")" is important for milestones.
 
     $tokens = preg_split('/[\s\[\]\(\)-]+/u', $string);
     $tokens = array_unique($tokens);
 
     // Make sure we don't return the empty token, as this will boil down to a
     // JOIN against every token.
     foreach ($tokens as $key => $value) {
       if (!strlen($value)) {
         unset($tokens[$key]);
       }
     }
 
     return array_values($tokens);
   }
 
   public function getTokens() {
     return self::tokenizeString($this->getRawQuery());
   }
 
   protected function executeQuery(
     PhabricatorCursorPagedPolicyAwareQuery $query) {
 
     return $query
       ->setViewer($this->getViewer())
       ->setOffset($this->getOffset())
       ->setLimit($this->getLimit())
       ->execute();
   }
 
 
   /**
    * Can the user browse through results from this datasource?
    *
    * Browsable datasources allow the user to switch from typeahead mode to
    * a browse mode where they can scroll through all results.
    *
    * By default, datasources are browsable, but some datasources can not
    * generate a meaningful result set or can't filter results on the server.
    *
    * @return bool
    */
   public function isBrowsable() {
     return true;
   }
 
 
   /**
    * Filter a list of results, removing items which don't match the query
    * tokens.
    *
    * This is useful for datasources which return a static list of hard-coded
    * or configured results and can't easily do query filtering in a real
    * query class. Instead, they can just build the entire result set and use
    * this method to filter it.
    *
    * For datasources backed by database objects, this is often much less
    * efficient than filtering at the query level.
    *
-   * @param list<PhabricatorTypeaheadResult> List of typeahead results.
+   * @param list<PhabricatorTypeaheadResult> $results List of typeahead results.
    * @return list<PhabricatorTypeaheadResult> Filtered results.
    */
   protected function filterResultsAgainstTokens(array $results) {
     $tokens = $this->getTokens();
     if (!$tokens) {
       return $results;
     }
 
     $map = array();
     foreach ($tokens as $token) {
       $map[$token] = strlen($token);
     }
 
     foreach ($results as $key => $result) {
       $rtokens = self::tokenizeString($result->getName());
 
       // For each token in the query, we need to find a match somewhere
       // in the result name.
       foreach ($map as $token => $length) {
         // Look for a match.
         $match = false;
         foreach ($rtokens as $rtoken) {
           if (!strncmp($rtoken, $token, $length)) {
             // This part of the result name has the query token as a prefix.
             $match = true;
             break;
           }
         }
 
         if (!$match) {
           // We didn't find a match for this query token, so throw the result
           // away. Try with the next result.
           unset($results[$key]);
           break;
         }
       }
     }
 
     return $results;
   }
 
   protected function newFunctionResult() {
     return id(new PhabricatorTypeaheadResult())
       ->setTokenType(PhabricatorTypeaheadTokenView::TYPE_FUNCTION)
       ->setIcon('fa-asterisk')
       ->addAttribute(pht('Function'));
   }
 
   public function newInvalidToken($name) {
     return id(new PhabricatorTypeaheadTokenView())
       ->setValue($name)
       ->setIcon('fa-exclamation-circle')
       ->setTokenType(PhabricatorTypeaheadTokenView::TYPE_INVALID);
   }
 
   public function renderTokens(array $values) {
     $phids = array();
     $setup = array();
     $tokens = array();
 
     foreach ($values as $key => $value) {
       if (!self::isFunctionToken($value)) {
         $phids[$key] = $value;
       } else {
         $function = $this->parseFunction($value);
         if ($function) {
           $setup[$function['name']][$key] = $function;
         } else {
           $name = pht('Invalid Function: %s', $value);
           $tokens[$key] = $this->newInvalidToken($name)
             ->setKey($value);
         }
       }
     }
 
     // Give special non-function tokens which are also not PHIDs (like statuses
     // and priorities) an opportunity to render.
     $type_unknown = PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN;
     $special = array();
     foreach ($values as $key => $value) {
       if (phid_get_type($value) == $type_unknown) {
         $special[$key] = $value;
       }
     }
 
     if ($special) {
       $special_tokens = $this->renderSpecialTokens($special);
       foreach ($special_tokens as $key => $token) {
         $tokens[$key] = $token;
         unset($phids[$key]);
       }
     }
 
     if ($phids) {
       $handles = $this->getViewer()->loadHandles($phids);
       foreach ($phids as $key => $phid) {
         $handle = $handles[$phid];
         $tokens[$key] = PhabricatorTypeaheadTokenView::newFromHandle($handle);
       }
     }
 
     if ($setup) {
       foreach ($setup as $function_name => $argv_list) {
         // Render the function tokens.
         $function_tokens = $this->renderFunctionTokens(
           $function_name,
           ipull($argv_list, 'argv'));
 
         // Rekey the function tokens using the original array keys.
         $function_tokens = array_combine(
           array_keys($argv_list),
           $function_tokens);
 
         // For any functions which were invalid, set their value to the
         // original input value before it was parsed.
         foreach ($function_tokens as $key => $token) {
           $type = $token->getTokenType();
           if ($type == PhabricatorTypeaheadTokenView::TYPE_INVALID) {
             $token->setKey($values[$key]);
           }
         }
 
         $tokens += $function_tokens;
       }
     }
 
     return array_select_keys($tokens, array_keys($values));
   }
 
   protected function renderSpecialTokens(array $values) {
     return array();
   }
 
 /* -(  Token Functions  )---------------------------------------------------- */
 
 
   /**
    * @task functions
    */
   public function getDatasourceFunctions() {
     return array();
   }
 
 
   /**
    * @task functions
    */
   public function getAllDatasourceFunctions() {
     return $this->getDatasourceFunctions();
   }
 
 
   /**
    * Check if this datasource requires a logged-in viewer.
    * @task functions
    * @param string $function Function name.
    * @return bool
    */
   protected function isFunctionWithLoginRequired($function) {
     // This is just a default.
     // Make sure to override this method to require login.
     return false;
   }
 
 
   /**
    * @task functions
    */
   protected function canEvaluateFunction($function) {
     return $this->shouldStripFunction($function);
   }
 
 
   /**
    * @task functions
    */
   protected function shouldStripFunction($function) {
     $functions = $this->getDatasourceFunctions();
     return isset($functions[$function]);
   }
 
 
   /**
    * @task functions
    */
   protected function evaluateFunction($function, array $argv_list) {
     throw new PhutilMethodNotImplementedException();
   }
 
 
   /**
    * @task functions
    */
   protected function evaluateValues(array $values) {
     return $values;
   }
 
 
   /**
    * @task functions
    */
   public function evaluateTokens(array $tokens) {
     $results = array();
     $evaluate = array();
     foreach ($tokens as $token) {
       if (!self::isFunctionToken($token)) {
         $results[] = $token;
       } else {
         // Put a placeholder in the result list so that we retain token order
         // when possible. We'll overwrite this below.
         $results[] = null;
         $evaluate[last_key($results)] = $token;
       }
     }
 
     $results = $this->evaluateValues($results);
 
     foreach ($evaluate as $result_key => $function) {
       $function = $this->parseFunction($function);
       if (!$function) {
         throw new PhabricatorTypeaheadInvalidTokenException();
       }
 
       $name = $function['name'];
       $argv = $function['argv'];
 
       $evaluated_tokens = $this->evaluateFunction($name, array($argv));
       if (!$evaluated_tokens) {
         unset($results[$result_key]);
       } else {
         $is_first = true;
         foreach ($evaluated_tokens as $phid) {
           if ($is_first) {
             $results[$result_key] = $phid;
             $is_first = false;
           } else {
             $results[] = $phid;
           }
         }
       }
     }
 
     $results = array_values($results);
     $results = $this->didEvaluateTokens($results);
 
     return $results;
   }
 
 
   /**
    * @task functions
    */
   protected function didEvaluateTokens(array $results) {
     return $results;
   }
 
 
   /**
    * @task functions
    */
   public static function isFunctionToken($token) {
     // We're looking for a "(" so that a string like "members(q" is identified
     // and parsed as a function call. This allows us to start generating
     // results immediately, before the user fully types out "members(quack)".
     if ($token) {
       return (strpos($token, '(') !== false);
     } else {
       return false;
     }
   }
 
 
   /**
    * @task functions
    */
   protected function parseFunction($token, $allow_partial = false) {
     $matches = null;
 
     if ($allow_partial) {
       $ok = preg_match('/^([^(]+)\((.*?)\)?\z/', $token, $matches);
     } else {
       $ok = preg_match('/^([^(]+)\((.*)\)\z/', $token, $matches);
     }
 
     if (!$ok) {
       if (!$allow_partial) {
         throw new PhabricatorTypeaheadInvalidTokenException(
           pht(
             'Unable to parse function and arguments for token "%s".',
             $token));
       }
       return null;
     }
 
     $function = trim($matches[1]);
 
     if (!$this->canEvaluateFunction($function)) {
       if (!$allow_partial) {
 
         if ($this->isFunctionWithLoginRequired($function)) {
           if (!$this->getViewer() || !$this->getViewer()->isLoggedIn()) {
             throw new PhabricatorTypeaheadLoginRequiredException(
               pht(
                 'This datasource ("%s") requires to be logged-in to use the '.
                 'function "%s(...)".',
                 get_class($this),
                 $function));
           }
         }
 
         throw new PhabricatorTypeaheadInvalidTokenException(
           pht(
             'This datasource ("%s") can not evaluate the function "%s(...)".',
             get_class($this),
             $function));
       }
 
       return null;
     }
 
     // TODO: There is currently no way to quote characters in arguments, so
     // some characters can't be argument characters. Replace this with a real
     // parser once we get use cases.
 
     $argv = $matches[2];
     $argv = trim($argv);
     if (!strlen($argv)) {
       $argv = array();
     } else {
       $argv = preg_split('/,/', $matches[2]);
       foreach ($argv as $key => $arg) {
         $argv[$key] = trim($arg);
       }
     }
 
     foreach ($argv as $key => $arg) {
       if (self::isFunctionToken($arg)) {
         $subfunction = $this->parseFunction($arg);
 
         $results = $this->evaluateFunction(
           $subfunction['name'],
           array($subfunction['argv']));
 
         $argv[$key] = head($results);
       }
     }
 
     return array(
       'name' => $function,
       'argv' => $argv,
     );
   }
 
 
   /**
    * @task functions
    */
   public function renderFunctionTokens($function, array $argv_list) {
     throw new PhutilMethodNotImplementedException();
   }
 
 
   /**
    * @task functions
    */
   public function setFunctionStack(array $function_stack) {
     $this->functionStack = $function_stack;
     return $this;
   }
 
 
   /**
    * @task functions
    */
   public function getFunctionStack() {
     return $this->functionStack;
   }
 
 
   /**
    * @task functions
    */
   protected function getCurrentFunction() {
     return nonempty(last($this->functionStack), null);
   }
 
   protected function renderTokensFromResults(array $results, array $values) {
     $tokens = array();
     foreach ($values as $key => $value) {
       if (empty($results[$value])) {
         continue;
       }
       $tokens[$key] = PhabricatorTypeaheadTokenView::newFromTypeaheadResult(
         $results[$value]);
     }
 
     return $tokens;
   }
 
   public function getWireTokens(array $values) {
     // TODO: This is a bit hacky for now: we're sort of generating wire
     // results, rendering them, then reverting them back to wire results. This
     // is pretty silly. It would probably be much cleaner to make
     // renderTokens() call this method instead, then render from the result
     // structure.
     $rendered = $this->renderTokens($values);
 
     $tokens = array();
     foreach ($rendered as $key => $render) {
       $tokens[$key] = id(new PhabricatorTypeaheadResult())
         ->setPHID($render->getKey())
         ->setIcon($render->getIcon())
         ->setColor($render->getColor())
         ->setDisplayName($render->getValue())
         ->setTokenType($render->getTokenType());
     }
 
     return mpull($tokens, 'getWireFormat', 'getPHID');
   }
 
   final protected function applyFerretConstraints(
     PhabricatorCursorPagedPolicyAwareQuery $query,
     PhabricatorFerretEngine $engine,
     $ferret_function,
     $raw_query) {
 
     $compiler = id(new PhutilSearchQueryCompiler())
       ->setEnableFunctions(true);
 
     $raw_tokens = $compiler->newTokens($raw_query);
 
     $fulltext_tokens = array();
     foreach ($raw_tokens as $raw_token) {
       // This is a little hacky and could maybe be cleaner. We're treating
       // every search term as though the user had entered "title:dog" instead
       // of "dog".
 
       $alternate_token = PhutilSearchQueryToken::newFromDictionary(
         array(
           'quoted' => $raw_token->isQuoted(),
           'value' => $raw_token->getValue(),
           'operator' => PhutilSearchQueryCompiler::OPERATOR_SUBSTRING,
           'function' => $ferret_function,
         ));
 
       $fulltext_token = id(new PhabricatorFulltextToken())
         ->setToken($alternate_token);
       $fulltext_tokens[] = $fulltext_token;
     }
 
     $query->withFerretConstraint($engine, $fulltext_tokens);
   }
 
 
 }
diff --git a/src/infrastructure/cache/PhutilInRequestKeyValueCache.php b/src/infrastructure/cache/PhutilInRequestKeyValueCache.php
index 2c303e860f..19edc81e2a 100644
--- a/src/infrastructure/cache/PhutilInRequestKeyValueCache.php
+++ b/src/infrastructure/cache/PhutilInRequestKeyValueCache.php
@@ -1,118 +1,118 @@
 <?php
 
 /**
  * Key-value cache implemented in the current request. All storage is local
  * to this request (i.e., the current page) and destroyed after the request
  * exits. This means the first request to this cache for a given key on a page
  * will ALWAYS miss.
  *
  * This cache exists mostly to support unit tests. In a well-designed
  * applications, you generally should not be fetching the same data over and
  * over again in one request, so this cache should be of limited utility.
  * If using this cache improves application performance, especially if it
  * improves it significantly, it may indicate an architectural problem in your
  * application.
  */
 final class PhutilInRequestKeyValueCache extends PhutilKeyValueCache {
 
   private $cache = array();
   private $ttl = array();
   private $limit = 0;
 
 
   /**
    * Set a limit on the number of keys this cache may contain.
    *
    * When too many keys are inserted, the oldest keys are removed from the
    * cache. Setting a limit of `0` disables the cache.
    *
-   * @param int Maximum number of items to store in the cache.
+   * @param int $limit Maximum number of items to store in the cache.
    * @return this
    */
   public function setLimit($limit) {
     $this->limit = $limit;
     return $this;
   }
 
 
 /* -(  Key-Value Cache Implementation  )------------------------------------- */
 
 
   public function isAvailable() {
     return true;
   }
 
   public function getKeys(array $keys) {
     $results = array();
     $now = time();
     foreach ($keys as $key) {
       if (!isset($this->cache[$key]) && !array_key_exists($key, $this->cache)) {
         continue;
       }
       if (isset($this->ttl[$key]) && ($this->ttl[$key] < $now)) {
         continue;
       }
       $results[$key] = $this->cache[$key];
     }
 
     return $results;
   }
 
   public function setKeys(array $keys, $ttl = null) {
 
     foreach ($keys as $key => $value) {
       $this->cache[$key] = $value;
     }
 
     if ($ttl) {
       $end = time() + $ttl;
       foreach ($keys as $key => $value) {
         $this->ttl[$key] = $end;
       }
     } else {
       foreach ($keys as $key => $value) {
         unset($this->ttl[$key]);
       }
     }
 
     if ($this->limit) {
       $count = count($this->cache);
       if ($count > $this->limit) {
         $remove = array();
         foreach ($this->cache as $key => $value) {
           $remove[] = $key;
 
           $count--;
           if ($count <= $this->limit) {
             break;
           }
         }
 
         $this->deleteKeys($remove);
       }
     }
 
     return $this;
   }
 
   public function deleteKeys(array $keys) {
     foreach ($keys as $key) {
       unset($this->cache[$key]);
       unset($this->ttl[$key]);
     }
 
     return $this;
   }
 
   public function getAllKeys() {
     return $this->cache;
   }
 
   public function destroyCache() {
     $this->cache = array();
     $this->ttl = array();
 
     return $this;
   }
 
 }
diff --git a/src/infrastructure/cache/PhutilKeyValueCache.php b/src/infrastructure/cache/PhutilKeyValueCache.php
index 8260df1ef2..e65eb9675c 100644
--- a/src/infrastructure/cache/PhutilKeyValueCache.php
+++ b/src/infrastructure/cache/PhutilKeyValueCache.php
@@ -1,121 +1,121 @@
 <?php
 
 /**
  * Interface to a key-value cache like Memcache or APC. This class provides a
  * uniform interface to multiple different key-value caches and integration
  * with PhutilServiceProfiler.
  *
  * @task  kvimpl    Key-Value Cache Implementation
  */
 abstract class PhutilKeyValueCache extends Phobject {
 
 
 /* -(  Key-Value Cache Implementation  )------------------------------------- */
 
 
   /**
    * Determine if the cache is available. For example, the APC cache tests if
    * APC is installed. If this method returns false, the cache is not
    * operational and can not be used.
    *
    * @return bool True if the cache can be used.
    * @task kvimpl
    */
   public function isAvailable() {
     return false;
   }
 
 
   /**
    * Get a single key from cache. See @{method:getKeys} to get multiple keys at
    * once.
    *
-   * @param   string  Key to retrieve.
-   * @param   wild    Optional value to return if the key is not found. By
-   *                  default, returns null.
+   * @param   string  $key Key to retrieve.
+   * @param   wild?   $default Optional value to return if the key is not
+   *                  found. By default, returns null.
    * @return  wild    Cache value (on cache hit) or default value (on cache
    *                  miss).
    * @task kvimpl
    */
   final public function getKey($key, $default = null) {
     $map = $this->getKeys(array($key));
     return idx($map, $key, $default);
   }
 
 
   /**
    * Set a single key in cache. See @{method:setKeys} to set multiple keys at
    * once.
    *
    * See @{method:setKeys} for a description of TTLs.
    *
-   * @param   string    Key to set.
-   * @param   wild      Value to set.
-   * @param   int|null  Optional TTL.
+   * @param   string    $key Key to set.
+   * @param   wild      $value Value to set.
+   * @param   int|null? $ttl Optional TTL.
    * @return  this
    * @task kvimpl
    */
   final public function setKey($key, $value, $ttl = null) {
     return $this->setKeys(array($key => $value), $ttl);
   }
 
 
   /**
    * Delete a key from the cache. See @{method:deleteKeys} to delete multiple
    * keys at once.
    *
-   * @param   string  Key to delete.
+   * @param   string $key Key to delete.
    * @return  this
    * @task kvimpl
    */
   final public function deleteKey($key) {
     return $this->deleteKeys(array($key));
   }
 
 
   /**
    * Get data from the cache.
    *
-   * @param   list<string>        List of cache keys to retrieve.
+   * @param   list<string>        $keys List of cache keys to retrieve.
    * @return  dict<string, wild>  Dictionary of keys that were found in the
    *                              cache. Keys not present in the cache are
    *                              omitted, so you can detect a cache miss.
    * @task kvimpl
    */
   abstract public function getKeys(array $keys);
 
 
   /**
    * Put data into the key-value cache.
    *
    * With a TTL ("time to live"), the cache will automatically delete the key
    * after a specified number of seconds. By default, there is no expiration
    * policy and data will persist in cache indefinitely.
    *
-   * @param dict<string, wild>  Map of cache keys to values.
-   * @param int|null            TTL for cache keys, in seconds.
+   * @param dict<string, wild>  $keys Map of cache keys to values.
+   * @param int|null?           $ttl TTL for cache keys, in seconds.
    * @return this
    * @task kvimpl
    */
   abstract public function setKeys(array $keys, $ttl = null);
 
 
   /**
    * Delete a list of keys from the cache.
    *
-   * @param list<string> List of keys to delete.
+   * @param list<string> $keys List of keys to delete.
    * @return this
    * @task kvimpl
    */
   abstract public function deleteKeys(array $keys);
 
 
   /**
    * Completely destroy all data in the cache.
    *
    * @return this
    * @task kvimpl
    */
   abstract public function destroyCache();
 
 }
diff --git a/src/infrastructure/cache/PhutilKeyValueCacheProfiler.php b/src/infrastructure/cache/PhutilKeyValueCacheProfiler.php
index 9e2271b530..f5d6979c92 100644
--- a/src/infrastructure/cache/PhutilKeyValueCacheProfiler.php
+++ b/src/infrastructure/cache/PhutilKeyValueCacheProfiler.php
@@ -1,108 +1,108 @@
 <?php
 
 final class PhutilKeyValueCacheProfiler extends PhutilKeyValueCacheProxy {
 
   private $profiler;
   private $name;
 
   public function setName($name) {
     $this->name = $name;
     return $this;
   }
 
   public function getName() {
     return $this->name;
   }
 
   /**
    * Set a profiler for cache operations.
    *
-   * @param PhutilServiceProfiler Service profiler.
+   * @param PhutilServiceProfiler $profiler Service profiler.
    * @return this
    * @task kvimpl
    */
   public function setProfiler(PhutilServiceProfiler $profiler) {
     $this->profiler = $profiler;
     return $this;
   }
 
 
   /**
    * Get the current profiler.
    *
    * @return PhutilServiceProfiler|null Profiler, or null if none is set.
    * @task kvimpl
    */
   public function getProfiler() {
     return $this->profiler;
   }
 
 
   public function getKeys(array $keys) {
     $call_id = null;
     if ($this->getProfiler()) {
       $call_id = $this->getProfiler()->beginServiceCall(
         array(
           'type' => 'kvcache-get',
           'name' => $this->getName(),
           'keys' => $keys,
         ));
     }
 
     $results = parent::getKeys($keys);
 
     if ($call_id !== null) {
       $this->getProfiler()->endServiceCall(
         $call_id,
         array(
           'hits' => array_keys($results),
         ));
     }
 
     return $results;
   }
 
 
   public function setKeys(array $keys, $ttl = null) {
     $call_id = null;
     if ($this->getProfiler()) {
       $call_id = $this->getProfiler()->beginServiceCall(
         array(
           'type' => 'kvcache-set',
           'name' => $this->getName(),
           'keys' => array_keys($keys),
           'ttl'  => $ttl,
         ));
     }
 
     $result = parent::setKeys($keys, $ttl);
 
     if ($call_id !== null) {
       $this->getProfiler()->endServiceCall($call_id, array());
     }
 
     return $result;
   }
 
 
   public function deleteKeys(array $keys) {
     $call_id = null;
     if ($this->getProfiler()) {
       $call_id = $this->getProfiler()->beginServiceCall(
         array(
           'type' => 'kvcache-del',
           'name' => $this->getName(),
           'keys' => $keys,
         ));
     }
 
     $result = parent::deleteKeys($keys);
 
     if ($call_id !== null) {
       $this->getProfiler()->endServiceCall($call_id, array());
     }
 
     return $result;
   }
 
 }
diff --git a/src/infrastructure/cache/PhutilKeyValueCacheStack.php b/src/infrastructure/cache/PhutilKeyValueCacheStack.php
index 76ea643be4..eab1ff86c5 100644
--- a/src/infrastructure/cache/PhutilKeyValueCacheStack.php
+++ b/src/infrastructure/cache/PhutilKeyValueCacheStack.php
@@ -1,131 +1,132 @@
 <?php
 
 /**
  * Stacks multiple caches on top of each other, with readthrough semantics:
  *
  *   - For reads, we try each cache in order until we find all the keys.
  *   - For writes, we set the keys in each cache.
  *
  * @task  config    Configuring the Stack
  */
 final class PhutilKeyValueCacheStack extends PhutilKeyValueCache {
 
 
   /**
    * Forward list of caches in the stack (from the nearest cache to the farthest
    * cache).
    */
   private $cachesForward;
 
 
   /**
    * Backward list of caches in the stack (from the farthest cache to the
    * nearest cache).
    */
   private $cachesBackward;
 
 
   /**
    * TTL to use for any writes which are side effects of the next read
    * operation.
    */
   private $nextTTL;
 
 
 /* -(  Configuring the Stack  )---------------------------------------------- */
 
 
   /**
    * Set the caches which comprise this stack.
    *
-   * @param   list<PhutilKeyValueCache> Ordered list of key-value caches.
+   * @param   list<PhutilKeyValueCache> $caches Ordered list of key-value
+   *   caches.
    * @return  this
    * @task    config
    */
   public function setCaches(array $caches) {
     assert_instances_of($caches, 'PhutilKeyValueCache');
     $this->cachesForward  = $caches;
     $this->cachesBackward = array_reverse($caches);
 
     return $this;
   }
 
 
   /**
    * Set the readthrough TTL for the next cache operation. The TTL applies to
    * any keys set by the next call to @{method:getKey} or @{method:getKeys},
    * and is reset after the call finishes.
    *
    *   // If this causes any caches to fill, they'll fill with a 15-second TTL.
    *   $stack->setNextTTL(15)->getKey('porcupine');
    *
    *   // TTL does not persist; this will use no TTL.
    *   $stack->getKey('hedgehog');
    *
-   * @param   int TTL in seconds.
+   * @param   int $ttl TTL in seconds.
    * @return  this
    *
    * @task    config
    */
   public function setNextTTL($ttl) {
     $this->nextTTL = $ttl;
     return $this;
   }
 
 
 /* -(  Key-Value Cache Implementation  )------------------------------------- */
 
 
   public function getKeys(array $keys) {
 
     $remaining = array_fuse($keys);
     $results = array();
     $missed = array();
 
     try {
       foreach ($this->cachesForward as $cache) {
         $result = $cache->getKeys($remaining);
         $remaining = array_diff_key($remaining, $result);
         $results += $result;
         if (!$remaining) {
           while ($cache = array_pop($missed)) {
             // TODO: This sets too many results in the closer caches, although
             // it probably isn't a big deal in most cases; normally we're just
             // filling the request cache.
             $cache->setKeys($result, $this->nextTTL);
           }
           break;
         }
         $missed[] = $cache;
       }
       $this->nextTTL = null;
     } catch (Exception $ex) {
       $this->nextTTL = null;
       throw $ex;
     }
 
     return $results;
   }
 
 
   public function setKeys(array $keys, $ttl = null) {
     foreach ($this->cachesBackward as $cache) {
       $cache->setKeys($keys, $ttl);
     }
   }
 
 
   public function deleteKeys(array $keys) {
     foreach ($this->cachesBackward as $cache) {
       $cache->deleteKeys($keys);
     }
   }
 
 
   public function destroyCache() {
     foreach ($this->cachesBackward as $cache) {
       $cache->destroyCache();
     }
   }
 
 }
diff --git a/src/infrastructure/cache/PhutilMemcacheKeyValueCache.php b/src/infrastructure/cache/PhutilMemcacheKeyValueCache.php
index d095eff706..918971ac28 100644
--- a/src/infrastructure/cache/PhutilMemcacheKeyValueCache.php
+++ b/src/infrastructure/cache/PhutilMemcacheKeyValueCache.php
@@ -1,153 +1,153 @@
 <?php
 
 /**
  * @task  memcache Managing Memcache
  */
 final class PhutilMemcacheKeyValueCache extends PhutilKeyValueCache {
 
   private $servers = array();
   private $connections = array();
 
 
 /* -(  Key-Value Cache Implementation  )------------------------------------- */
 
 
   public function isAvailable() {
     return function_exists('memcache_pconnect');
   }
 
   public function getKeys(array $keys) {
     $buckets = $this->bucketKeys($keys);
     $results = array();
 
     foreach ($buckets as $bucket => $bucket_keys) {
       $conn = $this->getConnection($bucket);
       $result = $conn->get($bucket_keys);
       if (!$result) {
         // If the call fails, treat it as a miss on all keys.
         $result = array();
       }
 
       $results += $result;
     }
 
     return $results;
   }
 
   public function setKeys(array $keys, $ttl = null) {
     $buckets = $this->bucketKeys(array_keys($keys));
 
     // Memcache interprets TTLs as:
     //
     //   - Seconds from now, for values from 1 to 2592000 (30 days).
     //   - Epoch timestamp, for values larger than 2592000.
     //
     // We support only relative TTLs, so convert excessively large relative
     // TTLs into epoch TTLs.
     if ($ttl > 2592000) {
       $effective_ttl = time() + $ttl;
     } else {
       $effective_ttl = $ttl;
     }
 
     foreach ($buckets as $bucket => $bucket_keys) {
       $conn = $this->getConnection($bucket);
 
       foreach ($bucket_keys as $key) {
         $conn->set($key, $keys[$key], 0, $effective_ttl);
       }
     }
 
     return $this;
   }
 
   public function deleteKeys(array $keys) {
     $buckets = $this->bucketKeys($keys);
 
     foreach ($buckets as $bucket => $bucket_keys) {
       $conn = $this->getConnection($bucket);
       foreach ($bucket_keys as $key) {
         $conn->delete($key);
       }
     }
 
     return $this;
   }
 
   public function destroyCache() {
     foreach ($this->servers as $key => $spec) {
       $this->getConnection($key)->flush();
     }
     return $this;
   }
 
 
 /* -(  Managing Memcache  )-------------------------------------------------- */
 
 
   /**
    * Set available memcache servers. For example:
    *
    *   $cache->setServers(
    *     array(
    *       array(
    *         'host' => '10.0.0.20',
    *         'port' => 11211,
    *       ),
    *       array(
    *         'host' => '10.0.0.21',
    *         'port' => 11211,
    *       ),
    *    ));
    *
-   * @param   list<dict>  List of server specifications.
+   * @param   list<dict> $servers List of server specifications.
    * @return  this
    * @task memcache
    */
   public function setServers(array $servers) {
     $this->servers = array_values($servers);
     return $this;
   }
 
   private function bucketKeys(array $keys) {
     $buckets = array();
     $n = count($this->servers);
 
     if (!$n) {
       throw new PhutilInvalidStateException('setServers');
     }
 
     foreach ($keys as $key) {
       $bucket = (int)((crc32($key) & 0x7FFFFFFF) % $n);
       $buckets[$bucket][] = $key;
     }
 
     return $buckets;
   }
 
 
   /**
    * @phutil-external-symbol function memcache_pconnect
    */
   private function getConnection($server) {
     if (empty($this->connections[$server])) {
       $spec = $this->servers[$server];
       $host = $spec['host'];
       $port = $spec['port'];
 
       $conn = memcache_pconnect($host, $spec['port']);
 
       if (!$conn) {
         throw new Exception(
           pht(
             'Unable to connect to memcache server (%s:%d)!',
             $host,
             $port));
       }
 
       $this->connections[$server] = $conn;
     }
     return $this->connections[$server];
   }
 
 }
diff --git a/src/infrastructure/contentsource/PhabricatorContentSource.php b/src/infrastructure/contentsource/PhabricatorContentSource.php
index de36f50b1f..19f954e0d8 100644
--- a/src/infrastructure/contentsource/PhabricatorContentSource.php
+++ b/src/infrastructure/contentsource/PhabricatorContentSource.php
@@ -1,99 +1,99 @@
 <?php
 
 abstract class PhabricatorContentSource extends Phobject {
 
   private $source;
   private $params = array();
 
   abstract public function getSourceName();
   abstract public function getSourceDescription();
 
   final public function getSourceTypeConstant() {
     return $this->getPhobjectClassConstant('SOURCECONST', 32);
   }
 
   final public static function getAllContentSources() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getSourceTypeConstant')
       ->execute();
   }
 
   /**
    * Construct a new content source object.
    *
-   * @param const The source type constant to build a source for.
-   * @param array Source parameters.
-   * @param bool True to suppress errors and force construction of a source
-   *   even if the source type is not valid.
+   * @param const $source The source type constant to build a source for.
+   * @param array? $params Source parameters.
+   * @param bool? $force True to suppress errors and force construction of a
+   *   source even if the source type is not valid.
    * @return PhabricatorContentSource New source object.
    */
   final public static function newForSource(
     $source,
     array $params = array(),
     $force = false) {
 
     $map = self::getAllContentSources();
     if (isset($map[$source])) {
       $obj = clone $map[$source];
     } else {
       if ($force) {
         $obj = new PhabricatorUnknownContentSource();
       } else {
         throw new Exception(
           pht(
             'Content source type "%s" is unknown.',
             $source));
       }
     }
 
     $obj->source = $source;
     $obj->params = $params;
 
     return $obj;
   }
 
   public static function newFromSerialized($serialized) {
     $dict = json_decode($serialized, true);
     if (!is_array($dict)) {
       $dict = array();
     }
 
     $source = idx($dict, 'source');
     $params = idx($dict, 'params');
     if (!is_array($params)) {
       $params = array();
     }
 
     return self::newForSource($source, $params, true);
   }
 
   public static function newFromRequest(AphrontRequest $request) {
     return self::newForSource(
       PhabricatorWebContentSource::SOURCECONST);
   }
 
   final public function serialize() {
     return phutil_json_encode(
       array(
         'source' => $this->getSource(),
         'params' => $this->params,
       ));
   }
 
   /**
    * Get the internal source name
    *
    * This is usually coming from a SOURCECONST constant.
    *
    * @return string|null
    */
   final public function getSource() {
     return $this->source;
   }
 
   final public function getContentSourceParameter($key, $default = null) {
     return idx($this->params, $key, $default);
   }
 
 }
diff --git a/src/infrastructure/customfield/field/PhabricatorCustomField.php b/src/infrastructure/customfield/field/PhabricatorCustomField.php
index 774dbb6630..c513b59c82 100644
--- a/src/infrastructure/customfield/field/PhabricatorCustomField.php
+++ b/src/infrastructure/customfield/field/PhabricatorCustomField.php
@@ -1,1722 +1,1727 @@
 <?php
 
 /**
  * @task apps         Building Applications with Custom Fields
  * @task core         Core Properties and Field Identity
  * @task proxy        Field Proxies
  * @task context      Contextual Data
  * @task render       Rendering Utilities
  * @task storage      Field Storage
  * @task edit         Integration with Edit Views
  * @task view         Integration with Property Views
  * @task list         Integration with List views
  * @task appsearch    Integration with ApplicationSearch
  * @task appxaction   Integration with ApplicationTransactions
  * @task xactionmail  Integration with Transaction Mail
  * @task globalsearch Integration with Global Search
  * @task herald       Integration with Herald
  */
 abstract class PhabricatorCustomField extends Phobject {
 
   private $viewer;
   private $object;
   private $proxy;
 
   const ROLE_APPLICATIONTRANSACTIONS  = 'ApplicationTransactions';
   const ROLE_TRANSACTIONMAIL          = 'ApplicationTransactions.mail';
   const ROLE_APPLICATIONSEARCH        = 'ApplicationSearch';
   const ROLE_STORAGE                  = 'storage';
   const ROLE_DEFAULT                  = 'default';
   const ROLE_EDIT                     = 'edit';
   const ROLE_VIEW                     = 'view';
   const ROLE_LIST                     = 'list';
   const ROLE_GLOBALSEARCH             = 'GlobalSearch';
   const ROLE_CONDUIT                  = 'conduit';
   const ROLE_HERALD                   = 'herald';
   const ROLE_EDITENGINE = 'EditEngine';
   const ROLE_HERALDACTION = 'herald.action';
   const ROLE_EXPORT = 'export';
 
 
 /* -(  Building Applications with Custom Fields  )--------------------------- */
 
 
   /**
    * @task apps
    */
   public static function getObjectFields(
     PhabricatorCustomFieldInterface $object,
     $role) {
 
     try {
       $attachment = $object->getCustomFields();
     } catch (PhabricatorDataNotAttachedException $ex) {
       $attachment = new PhabricatorCustomFieldAttachment();
       $object->attachCustomFields($attachment);
     }
 
     try {
       $field_list = $attachment->getCustomFieldList($role);
     } catch (PhabricatorCustomFieldNotAttachedException $ex) {
       $base_class = $object->getCustomFieldBaseClass();
 
       $spec = $object->getCustomFieldSpecificationForRole($role);
       if (!is_array($spec)) {
         throw new Exception(
           pht(
             "Expected an array from %s for object of class '%s'.",
             'getCustomFieldSpecificationForRole()',
             get_class($object)));
       }
 
       $fields = self::buildFieldList(
         $base_class,
         $spec,
         $object);
 
       $fields = self::adjustCustomFieldsForObjectSubtype(
         $object,
         $role,
         $fields);
 
       foreach ($fields as $key => $field) {
         // NOTE: We perform this filtering in "buildFieldList()", but may need
         // to filter again after subtype adjustment.
         if (!$field->isFieldEnabled()) {
           unset($fields[$key]);
           continue;
         }
 
         if (!$field->shouldEnableForRole($role)) {
           unset($fields[$key]);
           continue;
         }
       }
 
       foreach ($fields as $field) {
         $field->setObject($object);
       }
 
       $field_list = new PhabricatorCustomFieldList($fields);
       $attachment->addCustomFieldList($role, $field_list);
     }
 
     return $field_list;
   }
 
 
   /**
    * @task apps
    */
   public static function getObjectField(
     PhabricatorCustomFieldInterface $object,
     $role,
     $field_key) {
 
     $fields = self::getObjectFields($object, $role)->getFields();
 
     return idx($fields, $field_key);
   }
 
 
   /**
    * @task apps
    */
   public static function buildFieldList(
     $base_class,
     array $spec,
     $object,
     array $options = array()) {
 
     $field_objects = id(new PhutilClassMapQuery())
       ->setAncestorClass($base_class)
       ->execute();
 
     $fields = array();
     foreach ($field_objects as $field_object) {
       $field_object = clone $field_object;
       foreach ($field_object->createFields($object) as $field) {
         $key = $field->getFieldKey();
         if (isset($fields[$key])) {
           throw new Exception(
             pht(
               "Both '%s' and '%s' define a custom field with ".
               "field key '%s'. Field keys must be unique.",
               get_class($fields[$key]),
               get_class($field),
               $key));
         }
         $fields[$key] = $field;
       }
     }
 
     foreach ($fields as $key => $field) {
       if (!$field->isFieldEnabled()) {
         unset($fields[$key]);
       }
     }
 
     $fields = array_select_keys($fields, array_keys($spec)) + $fields;
 
     if (empty($options['withDisabled'])) {
       foreach ($fields as $key => $field) {
         if (isset($spec[$key]['disabled'])) {
           $is_disabled = $spec[$key]['disabled'];
         } else {
           $is_disabled = $field->shouldDisableByDefault();
         }
 
         if ($is_disabled) {
           if ($field->canDisableField()) {
             unset($fields[$key]);
           }
         }
       }
     }
 
     return $fields;
   }
 
 
 /* -(  Core Properties and Field Identity  )--------------------------------- */
 
 
   /**
    * Return a key which uniquely identifies this field, like
    * "mycompany:dinosaur:count". Normally you should provide some level of
    * namespacing to prevent collisions.
    *
    * @return string String which uniquely identifies this field.
    * @task core
    */
   public function getFieldKey() {
     if ($this->proxy) {
       return $this->proxy->getFieldKey();
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException(
       $this,
       $field_key_is_incomplete = true);
   }
 
   public function getModernFieldKey() {
     if ($this->proxy) {
       return $this->proxy->getModernFieldKey();
     }
     return $this->getFieldKey();
   }
 
 
   /**
    * Return a human-readable field name.
    *
    * @return string Human readable field name.
    * @task core
    */
   public function getFieldName() {
     if ($this->proxy) {
       return $this->proxy->getFieldName();
     }
     return $this->getModernFieldKey();
   }
 
 
   /**
    * Return a short, human-readable description of the field's behavior. This
    * provides more context to administrators when they are customizing fields.
    *
    * @return string|null Optional human-readable description.
    * @task core
    */
   public function getFieldDescription() {
     if ($this->proxy) {
       return $this->proxy->getFieldDescription();
     }
     return null;
   }
 
 
   /**
    * Most field implementations are unique, in that one class corresponds to
    * one field. However, some field implementations are general and a single
    * implementation may drive several fields.
    *
    * For general implementations, the general field implementation can return
    * multiple field instances here.
    *
-   * @param object The object to create fields for.
+   * @param object $object The object to create fields for.
    * @return list<PhabricatorCustomField> List of fields.
    * @task core
    */
   public function createFields($object) {
     return array($this);
   }
 
 
   /**
    * You can return `false` here if the field should not be enabled for any
    * role. For example, it might depend on something (like an application or
    * library) which isn't installed, or might have some global configuration
    * which allows it to be disabled.
    *
    * @return bool False to completely disable this field for all roles.
    * @task core
    */
   public function isFieldEnabled() {
     if ($this->proxy) {
       return $this->proxy->isFieldEnabled();
     }
     return true;
   }
 
 
   /**
    * Low level selector for field availability. Fields can appear in different
    * roles (like an edit view, a list view, etc.), but not every field needs
    * to appear everywhere. Fields that are disabled in a role won't appear in
    * that context within applications.
    *
    * Normally, you do not need to override this method. Instead, override the
    * methods specific to roles you want to enable. For example, implement
    * @{method:shouldUseStorage()} to activate the `'storage'` role.
    *
    * @return bool True to enable the field for the given role.
    * @task core
    */
   public function shouldEnableForRole($role) {
 
     // NOTE: All of these calls proxy individually, so we don't need to
     // proxy this call as a whole.
 
     switch ($role) {
       case self::ROLE_APPLICATIONTRANSACTIONS:
         return $this->shouldAppearInApplicationTransactions();
       case self::ROLE_APPLICATIONSEARCH:
         return $this->shouldAppearInApplicationSearch();
       case self::ROLE_STORAGE:
         return $this->shouldUseStorage();
       case self::ROLE_EDIT:
         return $this->shouldAppearInEditView();
       case self::ROLE_VIEW:
         return $this->shouldAppearInPropertyView();
       case self::ROLE_LIST:
         return $this->shouldAppearInListView();
       case self::ROLE_GLOBALSEARCH:
         return $this->shouldAppearInGlobalSearch();
       case self::ROLE_CONDUIT:
         return $this->shouldAppearInConduitDictionary();
       case self::ROLE_TRANSACTIONMAIL:
         return $this->shouldAppearInTransactionMail();
       case self::ROLE_HERALD:
         return $this->shouldAppearInHerald();
       case self::ROLE_HERALDACTION:
         return $this->shouldAppearInHeraldActions();
       case self::ROLE_EDITENGINE:
         return $this->shouldAppearInEditView() ||
                $this->shouldAppearInEditEngine();
       case self::ROLE_EXPORT:
         return $this->shouldAppearInDataExport();
       case self::ROLE_DEFAULT:
         return true;
       default:
         throw new Exception(pht("Unknown field role '%s'!", $role));
     }
   }
 
 
   /**
    * Allow administrators to disable this field. Most fields should allow this,
    * but some are fundamental to the behavior of the application and can be
    * locked down to avoid chaos, disorder, and the decline of civilization.
    *
    * @return bool False to prevent this field from being disabled through
    *              configuration.
    * @task core
    */
   public function canDisableField() {
     return true;
   }
 
   public function shouldDisableByDefault() {
     return false;
   }
 
 
   /**
    * Return an index string which uniquely identifies this field.
    *
    * @return string Index string which uniquely identifies this field.
    * @task core
    */
   final public function getFieldIndex() {
     return PhabricatorHash::digestForIndex($this->getFieldKey());
   }
 
 
 /* -(  Field Proxies  )------------------------------------------------------ */
 
 
   /**
    * Proxies allow a field to use some other field's implementation for most
    * of their behavior while still subclassing an application field. When a
    * proxy is set for a field with @{method:setProxy}, all of its methods will
    * call through to the proxy by default.
    *
    * This is most commonly used to implement configuration-driven custom fields
    * using @{class:PhabricatorStandardCustomField}.
    *
    * This method must be overridden to return `true` before a field can accept
    * proxies.
    *
    * @return bool True if you can @{method:setProxy} this field.
    * @task proxy
    */
   public function canSetProxy() {
     if ($this instanceof PhabricatorStandardCustomFieldInterface) {
       return true;
     }
     return false;
   }
 
 
   /**
    * Set the proxy implementation for this field. See @{method:canSetProxy} for
    * discussion of field proxies.
    *
-   * @param PhabricatorCustomField Field implementation.
+   * @param PhabricatorCustomField $proxy Field implementation.
    * @return this
    * @task proxy
    */
   final public function setProxy(PhabricatorCustomField $proxy) {
     if (!$this->canSetProxy()) {
       throw new PhabricatorCustomFieldNotProxyException($this);
     }
 
     $this->proxy = $proxy;
     return $this;
   }
 
 
   /**
    * Get the field's proxy implementation, if any. For discussion, see
    * @{method:canSetProxy}.
    *
    * @return PhabricatorCustomField|null  Proxy field, if one is set.
    * @task proxy
    */
   final public function getProxy() {
     return $this->proxy;
   }
 
   /**
    * @task proxy
    */
   public function __clone() {
     if ($this->proxy) {
       $this->proxy = clone $this->proxy;
     }
   }
 /* -(  Contextual Data  )---------------------------------------------------- */
 
 
   /**
    * Sets the object this field belongs to.
    *
-   * @param PhabricatorCustomFieldInterface The object this field belongs to.
+   * @param PhabricatorCustomFieldInterface $object The object this field
+   *   belongs to.
    * @return this
    * @task context
    */
   final public function setObject(PhabricatorCustomFieldInterface $object) {
     if ($this->proxy) {
       $this->proxy->setObject($object);
       return $this;
     }
 
     $this->object = $object;
     $this->didSetObject($object);
     return $this;
   }
 
 
   /**
    * Read object data into local field storage, if applicable.
    *
-   * @param PhabricatorCustomFieldInterface The object this field belongs to.
+   * @param PhabricatorCustomFieldInterface $object The object this field
+   *   belongs to.
    * @return this
    * @task context
    */
   public function readValueFromObject(PhabricatorCustomFieldInterface $object) {
     if ($this->proxy) {
       $this->proxy->readValueFromObject($object);
     }
     return $this;
   }
 
 
   /**
    * Get the object this field belongs to.
    *
    * @return PhabricatorCustomFieldInterface The object this field belongs to.
    * @task context
    */
   final public function getObject() {
     if ($this->proxy) {
       return $this->proxy->getObject();
     }
 
     return $this->object;
   }
 
 
   /**
    * This is a hook, primarily for subclasses to load object data.
    *
    * @return PhabricatorCustomFieldInterface The object this field belongs to.
    * @return void
    */
   protected function didSetObject(PhabricatorCustomFieldInterface $object) {
     return;
   }
 
 
   /**
    * @task context
    */
   final public function setViewer(PhabricatorUser $viewer) {
     if ($this->proxy) {
       $this->proxy->setViewer($viewer);
       return $this;
     }
 
     $this->viewer = $viewer;
     return $this;
   }
 
 
   /**
    * @task context
    */
   final public function getViewer() {
     if ($this->proxy) {
       return $this->proxy->getViewer();
     }
 
     return $this->viewer;
   }
 
 
   /**
    * @task context
    */
   final protected function requireViewer() {
     if ($this->proxy) {
       return $this->proxy->requireViewer();
     }
 
     if (!$this->viewer) {
       throw new PhabricatorCustomFieldDataNotAvailableException($this);
     }
     return $this->viewer;
   }
 
 
 /* -(  Rendering Utilities  )------------------------------------------------ */
 
 
   /**
    * @task render
    */
   protected function renderHandleList(array $handles) {
     if (!$handles) {
       return null;
     }
 
     $out = array();
     foreach ($handles as $handle) {
       $out[] = $handle->renderHovercardLink();
     }
 
     return phutil_implode_html(phutil_tag('br'), $out);
   }
 
 
 /* -(  Storage  )------------------------------------------------------------ */
 
 
   /**
    * Return true to use field storage.
    *
    * Fields which can be edited by the user will most commonly use storage,
    * while some other types of fields (for instance, those which just display
    * information in some stylized way) may not. Many builtin fields do not use
    * storage because their data is available on the object itself.
    *
    * If you implement this, you must also implement @{method:getValueForStorage}
    * and @{method:setValueFromStorage}.
    *
    * @return bool True to use storage.
    * @task storage
    */
   public function shouldUseStorage() {
     if ($this->proxy) {
       return $this->proxy->shouldUseStorage();
     }
     return false;
   }
 
 
   /**
    * Return a new, empty storage object. This should be a subclass of
    * @{class:PhabricatorCustomFieldStorage} which is bound to the application's
    * database.
    *
    * @return PhabricatorCustomFieldStorage New empty storage object.
    * @task storage
    */
   public function newStorageObject() {
     // NOTE: This intentionally isn't proxied, to avoid call cycles.
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * Return a serialized representation of the field value, appropriate for
    * storing in auxiliary field storage. You must implement this method if
    * you implement @{method:shouldUseStorage}.
    *
    * If the field value is a scalar, it can be returned unmodiifed. If not,
    * it should be serialized (for example, using JSON).
    *
    * @return string Serialized field value.
    * @task storage
    */
   public function getValueForStorage() {
     if ($this->proxy) {
       return $this->proxy->getValueForStorage();
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * Set the field's value given a serialized storage value. This is called
    * when the field is loaded; if no data is available, the value will be
    * null. You must implement this method if you implement
    * @{method:shouldUseStorage}.
    *
    * Usually, the value can be loaded directly. If it isn't a scalar, you'll
    * need to undo whatever serialization you applied in
    * @{method:getValueForStorage}.
    *
-   * @param string|null Serialized field representation (from
+   * @param string|null $value Serialized field representation (from
    *                    @{method:getValueForStorage}) or null if no value has
    *                    ever been stored.
    * @return this
    * @task storage
    */
   public function setValueFromStorage($value) {
     if ($this->proxy) {
       return $this->proxy->setValueFromStorage($value);
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
   public function didSetValueFromStorage() {
     if ($this->proxy) {
       return $this->proxy->didSetValueFromStorage();
     }
     return $this;
   }
 
 
 /* -(  ApplicationSearch  )-------------------------------------------------- */
 
 
   /**
    * Appearing in ApplicationSearch allows a field to be indexed and searched
    * for.
    *
    * @return bool True to appear in ApplicationSearch.
    * @task appsearch
    */
   public function shouldAppearInApplicationSearch() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInApplicationSearch();
     }
     return false;
   }
 
 
   /**
    * Return one or more indexes which this field can meaningfully query against
    * to implement ApplicationSearch.
    *
    * Normally, you should build these using @{method:newStringIndex} and
    * @{method:newNumericIndex}. For example, if a field holds a numeric value
    * it might return a single numeric index:
    *
    *   return array($this->newNumericIndex($this->getValue()));
    *
    * If a field holds a more complex value (like a list of users), it might
    * return several string indexes:
    *
    *   $indexes = array();
    *   foreach ($this->getValue() as $phid) {
    *     $indexes[] = $this->newStringIndex($phid);
    *   }
    *   return $indexes;
    *
    * @return list<PhabricatorCustomFieldIndexStorage> List of indexes.
    * @task appsearch
    */
   public function buildFieldIndexes() {
     if ($this->proxy) {
       return $this->proxy->buildFieldIndexes();
     }
     return array();
   }
 
 
   /**
    * Return an index against which this field can be meaningfully ordered
    * against to implement ApplicationSearch.
    *
    * This should be a single index, normally built using
    * @{method:newStringIndex} and @{method:newNumericIndex}.
    *
    * The value of the index is not used.
    *
    * Return null from this method if the field can not be ordered.
    *
    * @return PhabricatorCustomFieldIndexStorage A single index to order by.
    * @task appsearch
    */
   public function buildOrderIndex() {
     if ($this->proxy) {
       return $this->proxy->buildOrderIndex();
     }
     return null;
   }
 
 
   /**
    * Build a new empty storage object for storing string indexes. Normally,
    * this should be a concrete subclass of
    * @{class:PhabricatorCustomFieldStringIndexStorage}.
    *
    * @return PhabricatorCustomFieldStringIndexStorage Storage object.
    * @task appsearch
    */
   protected function newStringIndexStorage() {
     // NOTE: This intentionally isn't proxied, to avoid call cycles.
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * Build a new empty storage object for storing string indexes. Normally,
    * this should be a concrete subclass of
    * @{class:PhabricatorCustomFieldStringIndexStorage}.
    *
    * @return PhabricatorCustomFieldStringIndexStorage Storage object.
    * @task appsearch
    */
   protected function newNumericIndexStorage() {
     // NOTE: This intentionally isn't proxied, to avoid call cycles.
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * Build and populate storage for a string index.
    *
-   * @param string String to index.
+   * @param string $value String to index.
    * @return PhabricatorCustomFieldStringIndexStorage Populated storage.
    * @task appsearch
    */
   protected function newStringIndex($value) {
     if ($this->proxy) {
       return $this->proxy->newStringIndex();
     }
 
     $key = $this->getFieldIndex();
     return $this->newStringIndexStorage()
       ->setIndexKey($key)
       ->setIndexValue($value);
   }
 
 
   /**
    * Build and populate storage for a numeric index.
    *
-   * @param string Numeric value to index.
+   * @param string $value Numeric value to index.
    * @return PhabricatorCustomFieldNumericIndexStorage Populated storage.
    * @task appsearch
    */
   protected function newNumericIndex($value) {
     if ($this->proxy) {
       return $this->proxy->newNumericIndex();
     }
     $key = $this->getFieldIndex();
     return $this->newNumericIndexStorage()
       ->setIndexKey($key)
       ->setIndexValue($value);
   }
 
 
   /**
    * Read a query value from a request, for storage in a saved query. Normally,
    * this method should, e.g., read a string out of the request.
    *
-   * @param PhabricatorApplicationSearchEngine Engine building the query.
-   * @param AphrontRequest Request to read from.
+   * @param PhabricatorApplicationSearchEngine $engine Engine building the
+   *   query.
+   * @param AphrontRequest $request Request to read from.
    * @return wild
    * @task appsearch
    */
   public function readApplicationSearchValueFromRequest(
     PhabricatorApplicationSearchEngine $engine,
     AphrontRequest $request) {
     if ($this->proxy) {
       return $this->proxy->readApplicationSearchValueFromRequest(
         $engine,
         $request);
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * Constrain a query, given a field value. Generally, this method should
    * use `with...()` methods to apply filters or other constraints to the
    * query.
    *
-   * @param PhabricatorApplicationSearchEngine Engine executing the query.
-   * @param PhabricatorCursorPagedPolicyAwareQuery Query to constrain.
-   * @param wild Constraint provided by the user.
+   * @param PhabricatorApplicationSearchEngine $engine Engine executing the
+   *   query.
+   * @param PhabricatorCursorPagedPolicyAwareQuery $query Query to constrain.
+   * @param wild $value Constraint provided by the user.
    * @return void
    * @task appsearch
    */
   public function applyApplicationSearchConstraintToQuery(
     PhabricatorApplicationSearchEngine $engine,
     PhabricatorCursorPagedPolicyAwareQuery $query,
     $value) {
     if ($this->proxy) {
       return $this->proxy->applyApplicationSearchConstraintToQuery(
         $engine,
         $query,
         $value);
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * Append search controls to the interface.
    *
-   * @param PhabricatorApplicationSearchEngine Engine constructing the form.
-   * @param AphrontFormView The form to update.
-   * @param wild Value from the saved query.
+   * @param PhabricatorApplicationSearchEngine $engine Engine constructing the
+   *   form.
+   * @param AphrontFormView $form The form to update.
+   * @param wild $value Value from the saved query.
    * @return void
    * @task appsearch
    */
   public function appendToApplicationSearchForm(
     PhabricatorApplicationSearchEngine $engine,
     AphrontFormView $form,
     $value) {
     if ($this->proxy) {
       return $this->proxy->appendToApplicationSearchForm(
         $engine,
         $form,
         $value);
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
 /* -(  ApplicationTransactions  )-------------------------------------------- */
 
 
   /**
    * Appearing in ApplicationTransactions allows a field to be edited using
    * standard workflows.
    *
    * @return bool True to appear in ApplicationTransactions.
    * @task appxaction
    */
   public function shouldAppearInApplicationTransactions() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInApplicationTransactions();
     }
     return false;
   }
 
 
   /**
    * @task appxaction
    */
   public function getApplicationTransactionType() {
     if ($this->proxy) {
       return $this->proxy->getApplicationTransactionType();
     }
     return PhabricatorTransactions::TYPE_CUSTOMFIELD;
   }
 
 
   /**
    * @task appxaction
    */
   public function getApplicationTransactionMetadata() {
     if ($this->proxy) {
       return $this->proxy->getApplicationTransactionMetadata();
     }
     return array();
   }
 
 
   /**
    * @task appxaction
    */
   public function getOldValueForApplicationTransactions() {
     if ($this->proxy) {
       return $this->proxy->getOldValueForApplicationTransactions();
     }
     return $this->getValueForStorage();
   }
 
 
   /**
    * @task appxaction
    */
   public function getNewValueForApplicationTransactions() {
     if ($this->proxy) {
       return $this->proxy->getNewValueForApplicationTransactions();
     }
     return $this->getValueForStorage();
   }
 
 
   /**
    * @task appxaction
    */
   public function setValueFromApplicationTransactions($value) {
     if ($this->proxy) {
       return $this->proxy->setValueFromApplicationTransactions($value);
     }
     return $this->setValueFromStorage($value);
   }
 
 
   /**
    * @task appxaction
    */
   public function getNewValueFromApplicationTransactions(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->getNewValueFromApplicationTransactions($xaction);
     }
     return $xaction->getNewValue();
   }
 
 
   /**
    * @task appxaction
    */
   public function getApplicationTransactionHasEffect(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->getApplicationTransactionHasEffect($xaction);
     }
     return ($xaction->getOldValue() !== $xaction->getNewValue());
   }
 
 
   /**
    * @task appxaction
    */
   public function applyApplicationTransactionInternalEffects(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->applyApplicationTransactionInternalEffects($xaction);
     }
     return;
   }
 
 
   /**
    * @task appxaction
    */
   public function getApplicationTransactionRemarkupBlocks(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->getApplicationTransactionRemarkupBlocks($xaction);
     }
     return array();
   }
 
 
   /**
    * @task appxaction
    */
   public function applyApplicationTransactionExternalEffects(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->applyApplicationTransactionExternalEffects($xaction);
     }
 
     if (!$this->shouldEnableForRole(self::ROLE_STORAGE)) {
       return;
     }
 
     $this->setValueFromApplicationTransactions($xaction->getNewValue());
     $value = $this->getValueForStorage();
 
     $table = $this->newStorageObject();
     $conn_w = $table->establishConnection('w');
 
     if ($value === null) {
       queryfx(
         $conn_w,
         'DELETE FROM %T WHERE objectPHID = %s AND fieldIndex = %s',
         $table->getTableName(),
         $this->getObject()->getPHID(),
         $this->getFieldIndex());
     } else {
       queryfx(
         $conn_w,
         'INSERT INTO %T (objectPHID, fieldIndex, fieldValue)
           VALUES (%s, %s, %s)
           ON DUPLICATE KEY UPDATE fieldValue = VALUES(fieldValue)',
         $table->getTableName(),
         $this->getObject()->getPHID(),
         $this->getFieldIndex(),
         $value);
     }
 
     return;
   }
 
 
   /**
    * Validate transactions for an object. This allows you to raise an error
    * when a transaction would set a field to an invalid value, or when a field
    * is required but no transactions provide value.
    *
-   * @param PhabricatorLiskDAO Editor applying the transactions.
-   * @param string Transaction type. This type is always
+   * @param PhabricatorLiskDAO $editor Editor applying the transactions.
+   * @param string $type Transaction type. This type is always
    *   `PhabricatorTransactions::TYPE_CUSTOMFIELD`, it is provided for
    *   convenience when constructing exceptions.
-   * @param list<PhabricatorApplicationTransaction> Transactions being applied,
-   *   which may be empty if this field is not being edited.
+   * @param list<PhabricatorApplicationTransaction> $xactions Transactions
+   *   being applied, which may be empty if this field is not being edited.
    * @return list<PhabricatorApplicationTransactionValidationError> Validation
    *   errors.
    *
    * @task appxaction
    */
   public function validateApplicationTransactions(
     PhabricatorApplicationTransactionEditor $editor,
     $type,
     array $xactions) {
     if ($this->proxy) {
       return $this->proxy->validateApplicationTransactions(
         $editor,
         $type,
         $xactions);
     }
     return array();
   }
 
   public function getApplicationTransactionTitle(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->getApplicationTransactionTitle(
         $xaction);
     }
 
     $author_phid = $xaction->getAuthorPHID();
     return pht(
       '%s updated this object.',
       $xaction->renderHandleLink($author_phid));
   }
 
   public function getApplicationTransactionTitleForFeed(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->getApplicationTransactionTitleForFeed(
         $xaction);
     }
 
     $author_phid = $xaction->getAuthorPHID();
     $object_phid = $xaction->getObjectPHID();
     return pht(
       '%s updated %s.',
       $xaction->renderHandleLink($author_phid),
       $xaction->renderHandleLink($object_phid));
   }
 
 
   public function getApplicationTransactionHasChangeDetails(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->getApplicationTransactionHasChangeDetails(
         $xaction);
     }
     return false;
   }
 
   public function getApplicationTransactionChangeDetails(
     PhabricatorApplicationTransaction $xaction,
     PhabricatorUser $viewer) {
     if ($this->proxy) {
       return $this->proxy->getApplicationTransactionChangeDetails(
         $xaction,
         $viewer);
     }
     return null;
   }
 
   public function getApplicationTransactionRequiredHandlePHIDs(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->getApplicationTransactionRequiredHandlePHIDs(
         $xaction);
     }
     return array();
   }
 
   public function shouldHideInApplicationTransactions(
     PhabricatorApplicationTransaction $xaction) {
     if ($this->proxy) {
       return $this->proxy->shouldHideInApplicationTransactions($xaction);
     }
     return false;
   }
 
 
 /* -(  Transaction Mail  )--------------------------------------------------- */
 
 
   /**
    * @task xactionmail
    */
   public function shouldAppearInTransactionMail() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInTransactionMail();
     }
     return false;
   }
 
 
   /**
    * @task xactionmail
    */
   public function updateTransactionMailBody(
     PhabricatorMetaMTAMailBody $body,
     PhabricatorApplicationTransactionEditor $editor,
     array $xactions) {
     if ($this->proxy) {
       return $this->proxy->updateTransactionMailBody($body, $editor, $xactions);
     }
     return;
   }
 
 
 /* -(  Edit View  )---------------------------------------------------------- */
 
 
   public function getEditEngineFields(PhabricatorEditEngine $engine) {
     $field = $this->newStandardEditField();
 
     return array(
       $field,
     );
   }
 
   protected function newEditField() {
     $field = id(new PhabricatorCustomFieldEditField())
       ->setCustomField($this);
 
     $http_type = $this->getHTTPParameterType();
     if ($http_type) {
       $field->setCustomFieldHTTPParameterType($http_type);
     }
 
     $conduit_type = $this->getConduitEditParameterType();
     if ($conduit_type) {
       $field->setCustomFieldConduitParameterType($conduit_type);
     }
 
     $bulk_type = $this->getBulkParameterType();
     if ($bulk_type) {
       $field->setCustomFieldBulkParameterType($bulk_type);
     }
 
     $comment_action = $this->getCommentAction();
     if ($comment_action) {
       $field
         ->setCustomFieldCommentAction($comment_action)
         ->setCommentActionLabel(
           pht(
             'Change %s',
             $this->getFieldName()));
     }
 
     return $field;
   }
 
   protected function newStandardEditField() {
     if ($this->proxy) {
       return $this->proxy->newStandardEditField();
     }
 
     if ($this->shouldAppearInEditView()) {
       $form_field = true;
     } else {
       $form_field = false;
     }
 
     $bulk_label = $this->getBulkEditLabel();
 
     return $this->newEditField()
       ->setKey($this->getFieldKey())
       ->setEditTypeKey($this->getModernFieldKey())
       ->setLabel($this->getFieldName())
       ->setBulkEditLabel($bulk_label)
       ->setDescription($this->getFieldDescription())
       ->setTransactionType($this->getApplicationTransactionType())
       ->setIsFormField($form_field)
       ->setValue($this->getNewValueForApplicationTransactions());
   }
 
   protected function getBulkEditLabel() {
     if ($this->proxy) {
       return $this->proxy->getBulkEditLabel();
     }
 
     return pht('Set "%s" to', $this->getFieldName());
   }
 
   public function getBulkParameterType() {
     return $this->newBulkParameterType();
   }
 
   protected function newBulkParameterType() {
     if ($this->proxy) {
       return $this->proxy->newBulkParameterType();
     }
     return null;
   }
 
   protected function getHTTPParameterType() {
     if ($this->proxy) {
       return $this->proxy->getHTTPParameterType();
     }
     return null;
   }
 
   /**
    * @task edit
    */
   public function shouldAppearInEditView() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInEditView();
     }
     return false;
   }
 
   /**
    * @task edit
    */
   public function shouldAppearInEditEngine() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInEditEngine();
     }
     return false;
   }
 
 
   /**
    * @task edit
    */
   public function readValueFromRequest(AphrontRequest $request) {
     if ($this->proxy) {
       return $this->proxy->readValueFromRequest($request);
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * @task edit
    */
   public function getRequiredHandlePHIDsForEdit() {
     if ($this->proxy) {
       return $this->proxy->getRequiredHandlePHIDsForEdit();
     }
     return array();
   }
 
 
   /**
    * @task edit
    */
   public function getInstructionsForEdit() {
     if ($this->proxy) {
       return $this->proxy->getInstructionsForEdit();
     }
     return null;
   }
 
 
   /**
    * @task edit
    */
   public function renderEditControl(array $handles) {
     if ($this->proxy) {
       return $this->proxy->renderEditControl($handles);
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
 /* -(  Property View  )------------------------------------------------------ */
 
 
   /**
    * @task view
    */
   public function shouldAppearInPropertyView() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInPropertyView();
     }
     return false;
   }
 
 
   /**
    * @task view
    */
   public function renderPropertyViewLabel() {
     if ($this->proxy) {
       return $this->proxy->renderPropertyViewLabel();
     }
     return $this->getFieldName();
   }
 
 
   /**
    * @task view
    */
   public function renderPropertyViewValue(array $handles) {
     if ($this->proxy) {
       return $this->proxy->renderPropertyViewValue($handles);
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * @task view
    */
   public function getStyleForPropertyView() {
     if ($this->proxy) {
       return $this->proxy->getStyleForPropertyView();
     }
     return 'property';
   }
 
 
   /**
    * @task view
    */
   public function getIconForPropertyView() {
     if ($this->proxy) {
       return $this->proxy->getIconForPropertyView();
     }
     return null;
   }
 
 
   /**
    * @task view
    */
   public function getRequiredHandlePHIDsForPropertyView() {
     if ($this->proxy) {
       return $this->proxy->getRequiredHandlePHIDsForPropertyView();
     }
     return array();
   }
 
 
 /* -(  List View  )---------------------------------------------------------- */
 
 
   /**
    * @task list
    */
   public function shouldAppearInListView() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInListView();
     }
     return false;
   }
 
 
   /**
    * @task list
    */
   public function renderOnListItem(PHUIObjectItemView $view) {
     if ($this->proxy) {
       return $this->proxy->renderOnListItem($view);
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
 /* -(  Global Search  )------------------------------------------------------ */
 
 
   /**
    * @task globalsearch
    */
   public function shouldAppearInGlobalSearch() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInGlobalSearch();
     }
     return false;
   }
 
 
   /**
    * @task globalsearch
    */
   public function updateAbstractDocument(
     PhabricatorSearchAbstractDocument $document) {
     if ($this->proxy) {
       return $this->proxy->updateAbstractDocument($document);
     }
     return $document;
   }
 
 
 /* -(  Data Export  )-------------------------------------------------------- */
 
 
   public function shouldAppearInDataExport() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInDataExport();
     }
 
     try {
       $this->newExportFieldType();
       return true;
     } catch (PhabricatorCustomFieldImplementationIncompleteException $ex) {
       return false;
     }
   }
 
   public function newExportField() {
     if ($this->proxy) {
       return $this->proxy->newExportField();
     }
 
     return $this->newExportFieldType()
       ->setLabel($this->getFieldName());
   }
 
   public function newExportData() {
     if ($this->proxy) {
       return $this->proxy->newExportData();
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
   protected function newExportFieldType() {
     if ($this->proxy) {
       return $this->proxy->newExportFieldType();
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
 /* -(  Conduit  )------------------------------------------------------------ */
 
 
   /**
    * @task conduit
    */
   public function shouldAppearInConduitDictionary() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInConduitDictionary();
     }
     return false;
   }
 
 
   /**
    * @task conduit
    */
   public function getConduitDictionaryValue() {
     if ($this->proxy) {
       return $this->proxy->getConduitDictionaryValue();
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   public function shouldAppearInConduitTransactions() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInConduitDictionary();
     }
     return false;
   }
 
   public function getConduitSearchParameterType() {
     return $this->newConduitSearchParameterType();
   }
 
   protected function newConduitSearchParameterType() {
     if ($this->proxy) {
       return $this->proxy->newConduitSearchParameterType();
     }
     return null;
   }
 
   public function getConduitEditParameterType() {
     return $this->newConduitEditParameterType();
   }
 
   protected function newConduitEditParameterType() {
     if ($this->proxy) {
       return $this->proxy->newConduitEditParameterType();
     }
     return null;
   }
 
   public function getCommentAction() {
     return $this->newCommentAction();
   }
 
   protected function newCommentAction() {
     if ($this->proxy) {
       return $this->proxy->newCommentAction();
     }
     return null;
   }
 
 
 /* -(  Herald  )------------------------------------------------------------- */
 
 
   /**
    * Return `true` to make this field available in Herald.
    *
    * @return bool True to expose the field in Herald.
    * @task herald
    */
   public function shouldAppearInHerald() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInHerald();
     }
     return false;
   }
 
 
   /**
    * Get the name of the field in Herald. By default, this uses the
    * normal field name.
    *
    * @return string Herald field name.
    * @task herald
    */
   public function getHeraldFieldName() {
     if ($this->proxy) {
       return $this->proxy->getHeraldFieldName();
     }
     return $this->getFieldName();
   }
 
 
   /**
    * Get the field value for evaluation by Herald.
    *
    * @return wild Field value.
    * @task herald
    */
   public function getHeraldFieldValue() {
     if ($this->proxy) {
       return $this->proxy->getHeraldFieldValue();
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * Get the available conditions for this field in Herald.
    *
    * @return list<const> List of Herald condition constants.
    * @task herald
    */
   public function getHeraldFieldConditions() {
     if ($this->proxy) {
       return $this->proxy->getHeraldFieldConditions();
     }
     throw new PhabricatorCustomFieldImplementationIncompleteException($this);
   }
 
 
   /**
    * Get the Herald value type for the given condition.
    *
-   * @param   const       Herald condition constant.
+   * @param   const       $condition Herald condition constant.
    * @return  const|null  Herald value type, or null to use the default.
    * @task herald
    */
   public function getHeraldFieldValueType($condition) {
     if ($this->proxy) {
       return $this->proxy->getHeraldFieldValueType($condition);
     }
     return null;
   }
 
   public function getHeraldFieldStandardType() {
     if ($this->proxy) {
       return $this->proxy->getHeraldFieldStandardType();
     }
     return null;
   }
 
   public function getHeraldDatasource() {
     if ($this->proxy) {
       return $this->proxy->getHeraldDatasource();
     }
     return null;
   }
 
 
   public function shouldAppearInHeraldActions() {
     if ($this->proxy) {
       return $this->proxy->shouldAppearInHeraldActions();
     }
     return false;
   }
 
 
   public function getHeraldActionName() {
     if ($this->proxy) {
       return $this->proxy->getHeraldActionName();
     }
 
     return null;
   }
 
 
   public function getHeraldActionStandardType() {
     if ($this->proxy) {
       return $this->proxy->getHeraldActionStandardType();
     }
 
     return null;
   }
 
 
   public function getHeraldActionDescription($value) {
     if ($this->proxy) {
       return $this->proxy->getHeraldActionDescription($value);
     }
 
     return null;
   }
 
 
   public function getHeraldActionEffectDescription($value) {
     if ($this->proxy) {
       return $this->proxy->getHeraldActionEffectDescription($value);
     }
 
     return null;
   }
 
 
   public function getHeraldActionDatasource() {
     if ($this->proxy) {
       return $this->proxy->getHeraldActionDatasource();
     }
 
     return null;
   }
 
   private static function adjustCustomFieldsForObjectSubtype(
     PhabricatorCustomFieldInterface $object,
     $role,
     array $fields) {
     assert_instances_of($fields, __CLASS__);
 
     // We only apply subtype adjustment for some roles. For example, when
     // writing Herald rules or building a Search interface, we always want to
     // show all the fields in their default state, so we do not apply any
     // adjustments.
     $subtype_roles = array(
       self::ROLE_EDITENGINE,
       self::ROLE_VIEW,
       self::ROLE_EDIT,
     );
 
     $subtype_roles = array_fuse($subtype_roles);
     if (!isset($subtype_roles[$role])) {
       return $fields;
     }
 
     // If the object doesn't support subtypes, we can't possibly make
     // any adjustments based on subtype.
     if (!($object instanceof PhabricatorEditEngineSubtypeInterface)) {
       return $fields;
     }
 
     $subtype_map = $object->newEditEngineSubtypeMap();
     $subtype_key = $object->getEditEngineSubtype();
     $subtype_object = $subtype_map->getSubtype($subtype_key);
 
     $map = array();
     foreach ($fields as $field) {
       $modern_key = $field->getModernFieldKey();
       if (!strlen($modern_key)) {
         continue;
       }
 
       $map[$modern_key] = $field;
     }
 
     foreach ($map as $field_key => $field) {
       // For now, only support overriding standard custom fields. In the
       // future there's no technical or product reason we couldn't let you
       // override (some properties of) other fields like "Title", but they
       // don't usually support appropriate "setX()" methods today.
       if (!($field instanceof PhabricatorStandardCustomField)) {
         // For fields that are proxies on top of StandardCustomField, which
         // is how most application custom fields work today, we can reconfigure
         // the proxied field instead.
         $field = $field->getProxy();
         if (!$field || !($field instanceof PhabricatorStandardCustomField)) {
           continue;
         }
       }
 
       $subtype_config = $subtype_object->getSubtypeFieldConfiguration(
         $field_key);
 
       if (!$subtype_config) {
         continue;
       }
 
       if (isset($subtype_config['disabled'])) {
         $field->setIsEnabled(!$subtype_config['disabled']);
       }
 
       if (isset($subtype_config['name'])) {
         $field->setFieldName($subtype_config['name']);
       }
     }
 
     return $fields;
   }
 
 }
diff --git a/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php b/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php
index 369bed2297..e5f5c1698f 100644
--- a/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php
+++ b/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php
@@ -1,341 +1,342 @@
 <?php
 
 /**
  * Convenience class to perform operations on an entire field list, like reading
  * all values from storage.
  *
  *   $field_list = new PhabricatorCustomFieldList($fields);
  *
  */
 final class PhabricatorCustomFieldList extends Phobject {
 
   private $fields;
   private $viewer;
 
   public function __construct(array $fields) {
     assert_instances_of($fields, 'PhabricatorCustomField');
     $this->fields = $fields;
   }
 
   public function getFields() {
     return $this->fields;
   }
 
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     foreach ($this->getFields() as $field) {
       $field->setViewer($viewer);
     }
     return $this;
   }
 
   public function readFieldsFromObject(
     PhabricatorCustomFieldInterface $object) {
 
     $fields = $this->getFields();
 
     foreach ($fields as $field) {
       $field
         ->setObject($object)
         ->readValueFromObject($object);
     }
 
     return $this;
   }
 
   /**
    * Read stored values for all fields which support storage.
    *
-   * @param PhabricatorCustomFieldInterface Object to read field values for.
+   * @param PhabricatorCustomFieldInterface $object Object to read field values
+   *   for.
    * @return void
    */
   public function readFieldsFromStorage(
     PhabricatorCustomFieldInterface $object) {
 
     $this->readFieldsFromObject($object);
 
     $fields = $this->getFields();
     id(new PhabricatorCustomFieldStorageQuery())
       ->addFields($fields)
       ->execute();
 
     return $this;
   }
 
   public function appendFieldsToForm(AphrontFormView $form) {
     $enabled = array();
     foreach ($this->fields as $field) {
       if ($field->shouldEnableForRole(PhabricatorCustomField::ROLE_EDIT)) {
         $enabled[] = $field;
       }
     }
 
     $phids = array();
     foreach ($enabled as $field_key => $field) {
       $phids[$field_key] = $field->getRequiredHandlePHIDsForEdit();
     }
 
     $all_phids = array_mergev($phids);
     if ($all_phids) {
       $handles = id(new PhabricatorHandleQuery())
         ->setViewer($this->viewer)
         ->withPHIDs($all_phids)
         ->execute();
     } else {
       $handles = array();
     }
 
     foreach ($enabled as $field_key => $field) {
       $field_handles = array_select_keys($handles, $phids[$field_key]);
 
       $instructions = $field->getInstructionsForEdit();
       if (phutil_nonempty_string($instructions)) {
         $form->appendRemarkupInstructions($instructions);
       }
 
       $form->appendChild($field->renderEditControl($field_handles));
     }
   }
 
   public function appendFieldsToPropertyList(
     PhabricatorCustomFieldInterface $object,
     PhabricatorUser $viewer,
     PHUIPropertyListView $view) {
 
     $this->readFieldsFromStorage($object);
     $fields = $this->fields;
 
     foreach ($fields as $field) {
       $field->setViewer($viewer);
     }
 
     // Move all the blocks to the end, regardless of their configuration order,
     // because it always looks silly to render a block in the middle of a list
     // of properties.
     $head = array();
     $tail = array();
     foreach ($fields as $key => $field) {
       $style = $field->getStyleForPropertyView();
       switch ($style) {
         case 'property':
         case 'header':
           $head[$key] = $field;
           break;
         case 'block':
           $tail[$key] = $field;
           break;
         default:
           throw new Exception(
             pht(
               "Unknown field property view style '%s'; valid styles are ".
               "'%s' and '%s'.",
               $style,
               'block',
               'property'));
       }
     }
     $fields = $head + $tail;
 
     $add_header = null;
 
     $phids = array();
     foreach ($fields as $key => $field) {
       $phids[$key] = $field->getRequiredHandlePHIDsForPropertyView();
     }
 
     $all_phids = array_mergev($phids);
     if ($all_phids) {
       $handles = id(new PhabricatorHandleQuery())
         ->setViewer($viewer)
         ->withPHIDs($all_phids)
         ->execute();
     } else {
       $handles = array();
     }
 
     foreach ($fields as $key => $field) {
       $field_handles = array_select_keys($handles, $phids[$key]);
       $label = $field->renderPropertyViewLabel();
       $value = $field->renderPropertyViewValue($field_handles);
       if ($value !== null) {
         switch ($field->getStyleForPropertyView()) {
           case 'header':
             // We want to hide headers if the fields they're associated with
             // don't actually produce any visible properties. For example, in a
             // list like this:
             //
             //   Header A
             //   Prop A: Value A
             //   Header B
             //   Prop B: Value B
             //
             // ...if the "Prop A" field returns `null` when rendering its
             // property value and we rendered naively, we'd get this:
             //
             //   Header A
             //   Header B
             //   Prop B: Value B
             //
             // This is silly. Instead, we hide "Header A".
             $add_header = $value;
             break;
           case 'property':
             if ($add_header !== null) {
               // Add the most recently seen header.
               $view->addSectionHeader($add_header);
               $add_header = null;
             }
             $view->addProperty($label, $value);
             break;
           case 'block':
             $icon = $field->getIconForPropertyView();
             $view->invokeWillRenderEvent();
             if ($label !== null) {
               $view->addSectionHeader($label, $icon);
             }
             $view->addTextContent($value);
             break;
         }
       }
     }
   }
 
   public function addFieldsToListViewItem(
     PhabricatorCustomFieldInterface $object,
     PhabricatorUser $viewer,
     PHUIObjectItemView $view) {
 
     foreach ($this->fields as $field) {
       if ($field->shouldAppearInListView()) {
         $field->setViewer($viewer);
         $field->renderOnListItem($view);
       }
     }
   }
 
   public function buildFieldTransactionsFromRequest(
     PhabricatorApplicationTransaction $template,
     AphrontRequest $request) {
 
     $xactions = array();
 
     $role = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
     foreach ($this->fields as $field) {
       if (!$field->shouldEnableForRole($role)) {
         continue;
       }
 
       $transaction_type = $field->getApplicationTransactionType();
       $xaction = id(clone $template)
         ->setTransactionType($transaction_type);
 
       if ($transaction_type == PhabricatorTransactions::TYPE_CUSTOMFIELD) {
         // For TYPE_CUSTOMFIELD transactions only, we provide the old value
         // as an input.
         $old_value = $field->getOldValueForApplicationTransactions();
         $xaction->setOldValue($old_value);
       }
 
       $field->readValueFromRequest($request);
 
       $xaction
         ->setNewValue($field->getNewValueForApplicationTransactions());
 
       if ($transaction_type == PhabricatorTransactions::TYPE_CUSTOMFIELD) {
         // For TYPE_CUSTOMFIELD transactions, add the field key in metadata.
         $xaction->setMetadataValue('customfield:key', $field->getFieldKey());
       }
 
       $metadata = $field->getApplicationTransactionMetadata();
       foreach ($metadata as $key => $value) {
         $xaction->setMetadataValue($key, $value);
       }
 
       $xactions[] = $xaction;
     }
 
     return $xactions;
   }
 
 
   /**
    * Publish field indexes into index tables, so ApplicationSearch can search
    * them.
    *
    * @return void
    */
   public function rebuildIndexes(PhabricatorCustomFieldInterface $object) {
     $indexes = array();
     $index_keys = array();
 
     $phid = $object->getPHID();
 
     $role = PhabricatorCustomField::ROLE_APPLICATIONSEARCH;
     foreach ($this->fields as $field) {
       if (!$field->shouldEnableForRole($role)) {
         continue;
       }
 
       $index_keys[$field->getFieldIndex()] = true;
 
       foreach ($field->buildFieldIndexes() as $index) {
         $index->setObjectPHID($phid);
         $indexes[$index->getTableName()][] = $index;
       }
     }
 
     if (!$indexes) {
       return;
     }
 
     $any_index = head(head($indexes));
     $conn_w = $any_index->establishConnection('w');
 
     foreach ($indexes as $table => $index_list) {
       $sql = array();
       foreach ($index_list as $index) {
         $sql[] = $index->formatForInsert($conn_w);
       }
       $indexes[$table] = $sql;
     }
 
     $any_index->openTransaction();
 
       foreach ($indexes as $table => $sql_list) {
         queryfx(
           $conn_w,
           'DELETE FROM %T WHERE objectPHID = %s AND indexKey IN (%Ls)',
           $table,
           $phid,
           array_keys($index_keys));
 
         if (!$sql_list) {
           continue;
         }
 
         foreach (PhabricatorLiskDAO::chunkSQL($sql_list) as $chunk) {
           queryfx(
             $conn_w,
             'INSERT INTO %T (objectPHID, indexKey, indexValue) VALUES %LQ',
             $table,
             $chunk);
         }
       }
 
     $any_index->saveTransaction();
   }
 
   public function updateAbstractDocument(
     PhabricatorSearchAbstractDocument $document) {
 
     $role = PhabricatorCustomField::ROLE_GLOBALSEARCH;
     foreach ($this->getFields() as $field) {
       if (!$field->shouldEnableForRole($role)) {
         continue;
       }
       $field->updateAbstractDocument($document);
     }
   }
 
 
 }
diff --git a/src/infrastructure/customfield/storage/PhabricatorCustomFieldStorage.php b/src/infrastructure/customfield/storage/PhabricatorCustomFieldStorage.php
index cf0140a38a..111a16c968 100644
--- a/src/infrastructure/customfield/storage/PhabricatorCustomFieldStorage.php
+++ b/src/infrastructure/customfield/storage/PhabricatorCustomFieldStorage.php
@@ -1,94 +1,94 @@
 <?php
 
 abstract class PhabricatorCustomFieldStorage
   extends PhabricatorLiskDAO {
 
   protected $objectPHID;
   protected $fieldIndex;
   protected $fieldValue;
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_TIMESTAMPS => false,
       self::CONFIG_COLUMN_SCHEMA => array(
         'fieldIndex' => 'bytes12',
         'fieldValue' => 'text',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'objectPHID' => array(
           'columns' => array('objectPHID', 'fieldIndex'),
           'unique' => true,
         ),
       ),
     ) + parent::getConfiguration();
   }
 
 
   /**
    * Get a key which uniquely identifies this storage source.
    *
    * When loading custom fields, fields using sources with the same source key
    * are loaded in bulk.
    *
    * @return string Source identifier.
    */
   final public function getStorageSourceKey() {
     return $this->getApplicationName().'/'.$this->getTableName();
   }
 
 
   /**
    * Load stored data for custom fields.
    *
    * Given a map of fields, return a map with any stored data for those fields.
    * The keys in the result should correspond to the keys in the input. The
    * fields in the list may belong to different objects.
    *
-   * @param map<string, PhabricatorCustomField> Map of fields.
+   * @param map<string, PhabricatorCustomField> $fields Map of fields.
    * @return map<String, PhabricatorCustomField> Map of available field data.
    */
   final public function loadStorageSourceData(array $fields) {
     $map = array();
     $indexes = array();
     $object_phids = array();
 
     foreach ($fields as $key => $field) {
       $index = $field->getFieldIndex();
       $object_phid = $field->getObject()->getPHID();
 
       $map[$index][$object_phid] = $key;
       $indexes[$index] = $index;
       $object_phids[$object_phid] = $object_phid;
     }
 
     if (!$indexes) {
       return array();
     }
 
     $conn = $this->establishConnection('r');
     $rows = queryfx_all(
       $conn,
       'SELECT objectPHID, fieldIndex, fieldValue FROM %T
         WHERE objectPHID IN (%Ls) AND fieldIndex IN (%Ls)',
       $this->getTableName(),
       $object_phids,
       $indexes);
 
     $result = array();
     foreach ($rows as $row) {
       $index = $row['fieldIndex'];
       $object_phid = $row['objectPHID'];
       $value = $row['fieldValue'];
 
       if (!isset($map[$index]) || !isset($map[$index][$object_phid])) {
        continue;
       }
 
       $key = $map[$index][$object_phid];
       $result[$key] = $value;
     }
 
     return $result;
   }
 
 }
diff --git a/src/infrastructure/daemon/PhabricatorDaemon.php b/src/infrastructure/daemon/PhabricatorDaemon.php
index f42a59f134..0e203566f8 100644
--- a/src/infrastructure/daemon/PhabricatorDaemon.php
+++ b/src/infrastructure/daemon/PhabricatorDaemon.php
@@ -1,71 +1,71 @@
 <?php
 
 abstract class PhabricatorDaemon extends PhutilDaemon {
 
   protected function willRun() {
     parent::willRun();
 
     $phabricator = phutil_get_library_root('phabricator');
     $root = dirname($phabricator);
     require_once $root.'/scripts/__init_script__.php';
   }
 
   protected function willSleep($duration) {
     LiskDAO::closeInactiveConnections(60);
     return;
   }
 
   public function getViewer() {
     return PhabricatorUser::getOmnipotentUser();
   }
 
 
   /**
    * Format a command so it executes as the daemon user, if a daemon user is
    * defined. This wraps the provided command in `sudo -u ...`, roughly.
    *
-   * @param   PhutilCommandString Command to execute.
+   * @param   PhutilCommandString $command Command to execute.
    * @return  PhutilCommandString `sudo` version of the command.
    */
   public static function sudoCommandAsDaemonUser($command) {
     $user = PhabricatorEnv::getEnvConfig('phd.user');
     if (!$user) {
       // No daemon user is set, so just run this as ourselves.
       return $command;
     }
 
     // We may reach this method while already running as the daemon user: for
     // example, active and passive synchronization of clustered repositories
     // run the same commands through the same code, but as different users.
 
     // By default, `sudo` won't let you sudo to yourself, so we can get into
     // trouble if we're already running as the daemon user unless the host has
     // been configured to let the daemon user run commands as itself.
 
     // Since this is silly and more complicated than doing this check, don't
     // use `sudo` if we're already running as the correct user.
     if (function_exists('posix_getuid')) {
       $uid = posix_getuid();
       $info = posix_getpwuid($uid);
       if ($info && $info['name'] == $user) {
         return $command;
       }
     }
 
     // Get the absolute path so we're safe against the caller wiping out
     // PATH.
     $sudo = Filesystem::resolveBinary('sudo');
     if (!$sudo) {
       throw new Exception(pht("Unable to find 'sudo'!"));
     }
 
     // Flags here are:
     //
     //   -E: Preserve the environment.
     //   -n: Non-interactive. Exit with an error instead of prompting.
     //   -u: Which user to sudo to.
 
     return csprintf('%s -E -n -u %s -- %C', $sudo, $user, $command);
   }
 
 }
diff --git a/src/infrastructure/daemon/PhutilDaemonHandle.php b/src/infrastructure/daemon/PhutilDaemonHandle.php
index 3792c3b773..a44ba7b7cb 100644
--- a/src/infrastructure/daemon/PhutilDaemonHandle.php
+++ b/src/infrastructure/daemon/PhutilDaemonHandle.php
@@ -1,537 +1,537 @@
 <?php
 
 final class PhutilDaemonHandle extends Phobject {
 
   const EVENT_DID_LAUNCH    = 'daemon.didLaunch';
   const EVENT_DID_LOG       = 'daemon.didLogMessage';
   const EVENT_DID_HEARTBEAT = 'daemon.didHeartbeat';
   const EVENT_WILL_GRACEFUL = 'daemon.willGraceful';
   const EVENT_WILL_EXIT     = 'daemon.willExit';
 
   private $pool;
   private $properties;
   private $future;
   private $argv;
 
   private $restartAt;
   private $busyEpoch;
 
   private $daemonID;
   private $deadline;
   private $heartbeat;
   private $stdoutBuffer;
   private $shouldRestart = true;
   private $shouldShutdown;
   private $hibernating = false;
   private $shouldSendExitEvent = false;
 
   private function __construct() {
     // <empty>
   }
 
   public static function newFromConfig(array $config) {
     PhutilTypeSpec::checkMap(
       $config,
       array(
         'class' => 'string',
         'argv' => 'optional list<string>',
         'load' => 'optional list<string>',
         'log' => 'optional string|null',
         'down' => 'optional int',
       ));
 
     $config = $config + array(
       'argv' => array(),
       'load' => array(),
       'log' => null,
       'down' => 15,
     );
 
     $daemon = new self();
     $daemon->properties = $config;
     $daemon->daemonID = $daemon->generateDaemonID();
 
     return $daemon;
   }
 
   public function setDaemonPool(PhutilDaemonPool $daemon_pool) {
     $this->pool = $daemon_pool;
     return $this;
   }
 
   public function getDaemonPool() {
     return $this->pool;
   }
 
   public function getBusyEpoch() {
     return $this->busyEpoch;
   }
 
   public function getDaemonClass() {
     return $this->getProperty('class');
   }
 
   private function getProperty($key) {
     return idx($this->properties, $key);
   }
 
   public function setCommandLineArguments(array $arguments) {
     $this->argv = $arguments;
     return $this;
   }
 
   public function getCommandLineArguments() {
     return $this->argv;
   }
 
   public function getDaemonArguments() {
     return $this->getProperty('argv');
   }
 
   public function didLaunch() {
     $this->restartAt = time();
     $this->shouldSendExitEvent = true;
 
     $this->dispatchEvent(
       self::EVENT_DID_LAUNCH,
       array(
         'argv' => $this->getCommandLineArguments(),
         'explicitArgv' => $this->getDaemonArguments(),
       ));
 
     return $this;
   }
 
   public function isRunning() {
     return (bool)$this->getFuture();
   }
 
   public function isHibernating() {
     return
       !$this->isRunning() &&
       !$this->isDone() &&
       $this->hibernating;
   }
 
   public function wakeFromHibernation() {
     if (!$this->isHibernating()) {
       return $this;
     }
 
     $this->logMessage(
       'WAKE',
       pht(
         'Process is being awakened from hibernation.'));
 
     $this->restartAt = time();
     $this->update();
 
     return $this;
   }
 
   public function isDone() {
     return (!$this->shouldRestart && !$this->isRunning());
   }
 
   public function update() {
     if (!$this->isRunning()) {
       if (!$this->shouldRestart) {
         return;
       }
       if (!$this->restartAt || (time() < $this->restartAt)) {
         return;
       }
       if ($this->shouldShutdown) {
         return;
       }
       $this->startDaemonProcess();
     }
 
     $future = $this->getFuture();
 
     $result = null;
     $caught = null;
     if ($future->canResolve()) {
       $this->future = null;
       try {
         $result = $future->resolve();
       } catch (Exception $ex) {
         $caught = $ex;
       } catch (Throwable $ex) {
         $caught = $ex;
       }
     }
 
     list($stdout, $stderr) = $future->read();
     $future->discardBuffers();
 
     if (strlen($stdout)) {
       $this->didReadStdout($stdout);
     }
 
     $stderr = trim($stderr);
     if (strlen($stderr)) {
       foreach (phutil_split_lines($stderr, false) as $line) {
         $this->logMessage('STDE', $line);
       }
     }
 
     if ($result !== null || $caught !== null) {
 
       if ($caught) {
         $message = pht(
           'Process failed with exception: %s',
           $caught->getMessage());
         $this->logMessage('FAIL', $message);
       } else {
         list($err) = $result;
 
         if ($err) {
           $this->logMessage('FAIL', pht('Process exited with error %s.', $err));
         } else {
           $this->logMessage('DONE', pht('Process exited normally.'));
         }
       }
 
       if ($this->shouldShutdown) {
         $this->restartAt = null;
       } else {
         $this->scheduleRestart();
       }
     }
 
     $this->updateHeartbeatEvent();
     $this->updateHangDetection();
   }
 
   private function updateHeartbeatEvent() {
     if ($this->heartbeat > time()) {
       return;
     }
 
     $this->heartbeat = time() + $this->getHeartbeatEventFrequency();
     $this->dispatchEvent(self::EVENT_DID_HEARTBEAT);
   }
 
   private function updateHangDetection() {
     if (!$this->isRunning()) {
       return;
     }
 
     if (time() > $this->deadline) {
       $this->logMessage('HANG', pht('Hang detected. Restarting process.'));
       $this->annihilateProcessGroup();
       $this->scheduleRestart();
     }
   }
 
   private function scheduleRestart() {
     // Wait a minimum of a few sceconds before restarting, but we may wait
     // longer if the daemon has initiated hibernation.
     $default_restart = time() + self::getWaitBeforeRestart();
     if ($default_restart >= $this->restartAt) {
       $this->restartAt = $default_restart;
     }
 
     $this->logMessage(
       'WAIT',
       pht(
         'Waiting %s second(s) to restart process.',
         new PhutilNumber($this->restartAt - time())));
   }
 
   /**
    * Generate a unique ID for this daemon.
    *
    * @return string A unique daemon ID.
    */
   private function generateDaemonID() {
     return substr(getmypid().':'.Filesystem::readRandomCharacters(12), 0, 12);
   }
 
   public function getDaemonID() {
     return $this->daemonID;
   }
 
   private function getFuture() {
     return $this->future;
   }
 
   private function getPID() {
     $future = $this->getFuture();
 
     if (!$future) {
       return null;
     }
 
     if (!$future->hasPID()) {
       return null;
     }
 
     return $future->getPID();
   }
 
   private function getCaptureBufferSize() {
     return 65535;
   }
 
   private function getRequiredHeartbeatFrequency() {
     return 86400;
   }
 
   public static function getWaitBeforeRestart() {
     return 5;
   }
 
   public static function getHeartbeatEventFrequency() {
     return 120;
   }
 
   private function getKillDelay() {
     return 3;
   }
 
   private function getDaemonCWD() {
     $root = dirname(phutil_get_library_root('phabricator'));
     return $root.'/scripts/daemon/exec/';
   }
 
   private function newExecFuture() {
     $class = $this->getDaemonClass();
     $argv = $this->getCommandLineArguments();
     $buffer_size = $this->getCaptureBufferSize();
 
     // NOTE: PHP implements proc_open() by running 'sh -c'. On most systems this
     // is bash, but on Ubuntu it's dash. When you proc_open() using bash, you
     // get one new process (the command you ran). When you proc_open() using
     // dash, you get two new processes: the command you ran and a parent
     // "dash -c" (or "sh -c") process. This means that the child process's PID
     // is actually the 'dash' PID, not the command's PID. To avoid this, use
     // 'exec' to replace the shell process with the real process; without this,
     // the child will call posix_getppid(), be given the pid of the 'sh -c'
     // process, and send it SIGUSR1 to keepalive which will terminate it
     // immediately. We also won't be able to do process group management because
     // the shell process won't properly posix_setsid() so the pgid of the child
     // won't be meaningful.
 
     $config = $this->properties;
     unset($config['class']);
     $config = phutil_json_encode($config);
 
     return id(new ExecFuture('exec ./exec_daemon.php %s %Ls', $class, $argv))
       ->setCWD($this->getDaemonCWD())
       ->setStdoutSizeLimit($buffer_size)
       ->setStderrSizeLimit($buffer_size)
       ->write($config);
   }
 
   /**
    * Dispatch an event to event listeners.
    *
-   * @param  string Event type.
-   * @param  dict   Event parameters.
+   * @param  string $type Event type.
+   * @param  dict?  $params Event parameters.
    * @return void
    */
   private function dispatchEvent($type, array $params = array()) {
     $data = array(
       'id' => $this->getDaemonID(),
       'daemonClass' => $this->getDaemonClass(),
       'childPID' => $this->getPID(),
     ) + $params;
 
     $event = new PhutilEvent($type, $data);
 
     try {
       PhutilEventEngine::dispatchEvent($event);
     } catch (Exception $ex) {
       phlog($ex);
     }
   }
 
   private function annihilateProcessGroup() {
     $pid = $this->getPID();
     if ($pid) {
       $pgid = posix_getpgid($pid);
       if ($pgid) {
         posix_kill(-$pgid, SIGTERM);
         sleep($this->getKillDelay());
         posix_kill(-$pgid, SIGKILL);
       }
     }
   }
 
   private function startDaemonProcess() {
     $this->logMessage('INIT', pht('Starting process.'));
 
     $this->deadline = time() + $this->getRequiredHeartbeatFrequency();
     $this->heartbeat = time() + self::getHeartbeatEventFrequency();
     $this->stdoutBuffer = '';
     $this->hibernating = false;
 
     $future = $this->newExecFuture();
     $this->future = $future;
 
     $pool = $this->getDaemonPool();
     $overseer = $pool->getOverseer();
     $overseer->addFutureToPool($future);
   }
 
   private function didReadStdout($data) {
     $this->stdoutBuffer .= $data;
     while (true) {
       $pos = strpos($this->stdoutBuffer, "\n");
       if ($pos === false) {
         break;
       }
       $message = substr($this->stdoutBuffer, 0, $pos);
       $this->stdoutBuffer = substr($this->stdoutBuffer, $pos + 1);
 
       try {
         $structure = phutil_json_decode($message);
       } catch (PhutilJSONParserException $ex) {
         $structure = array();
       }
 
       switch (idx($structure, 0)) {
         case PhutilDaemon::MESSAGETYPE_STDOUT:
           $this->logMessage('STDO', idx($structure, 1));
           break;
         case PhutilDaemon::MESSAGETYPE_HEARTBEAT:
           $this->deadline = time() + $this->getRequiredHeartbeatFrequency();
           break;
         case PhutilDaemon::MESSAGETYPE_BUSY:
           if (!$this->busyEpoch) {
             $this->busyEpoch = time();
           }
           break;
         case PhutilDaemon::MESSAGETYPE_IDLE:
           $this->busyEpoch = null;
           break;
         case PhutilDaemon::MESSAGETYPE_DOWN:
           // The daemon is exiting because it doesn't have enough work and it
           // is trying to scale the pool down. We should not restart it.
           $this->shouldRestart = false;
           $this->shouldShutdown = true;
           break;
         case PhutilDaemon::MESSAGETYPE_HIBERNATE:
           $config = idx($structure, 1);
           $duration = (int)idx($config, 'duration', 0);
           $this->restartAt = time() + $duration;
           $this->hibernating = true;
           $this->busyEpoch = null;
           $this->logMessage(
             'ZZZZ',
             pht(
               'Process is preparing to hibernate for %s second(s).',
               new PhutilNumber($duration)));
           break;
         default:
           // If we can't parse this or it isn't a message we understand, just
           // emit the raw message.
           $this->logMessage('STDO', pht('<Malformed> %s', $message));
           break;
       }
     }
   }
 
   public function didReceiveNotifySignal($signo) {
     $pid = $this->getPID();
     if ($pid) {
       posix_kill($pid, $signo);
     }
   }
 
   public function didReceiveReloadSignal($signo) {
     $signame = phutil_get_signal_name($signo);
     if ($signame) {
       $sigmsg = pht(
         'Reloading in response to signal %d (%s).',
         $signo,
         $signame);
     } else {
       $sigmsg = pht(
         'Reloading in response to signal %d.',
         $signo);
     }
 
     $this->logMessage('RELO', $sigmsg, $signo);
 
     // This signal means "stop the current process gracefully, then launch
     // a new identical process once it exits". This can be used to update
     // daemons after code changes (the new processes will run the new code)
     // without aborting any running tasks.
 
     // We SIGINT the daemon but don't set the shutdown flag, so it will
     // naturally be restarted after it exits, as though it had exited after an
     // unhandled exception.
 
     $pid = $this->getPID();
     if ($pid) {
       posix_kill($pid, SIGINT);
     }
   }
 
   public function didReceiveGracefulSignal($signo) {
     $this->shouldShutdown = true;
     $this->shouldRestart = false;
 
     $signame = phutil_get_signal_name($signo);
     if ($signame) {
       $sigmsg = pht(
         'Graceful shutdown in response to signal %d (%s).',
         $signo,
         $signame);
     } else {
       $sigmsg = pht(
         'Graceful shutdown in response to signal %d.',
         $signo);
     }
 
     $this->logMessage('DONE', $sigmsg, $signo);
 
     $pid = $this->getPID();
     if ($pid) {
       posix_kill($pid, SIGINT);
     }
   }
 
   public function didReceiveTerminateSignal($signo) {
     $this->shouldShutdown = true;
     $this->shouldRestart = false;
 
     $signame = phutil_get_signal_name($signo);
     if ($signame) {
       $sigmsg = pht(
         'Shutting down in response to signal %s (%s).',
         $signo,
         $signame);
     } else {
       $sigmsg = pht('Shutting down in response to signal %s.', $signo);
     }
 
     $this->logMessage('EXIT', $sigmsg, $signo);
     $this->annihilateProcessGroup();
   }
 
   private function logMessage($type, $message, $context = null) {
     $this->getDaemonPool()->logMessage($type, $message, $context);
 
     $this->dispatchEvent(
       self::EVENT_DID_LOG,
       array(
         'type' => $type,
         'message' => $message,
         'context' => $context,
       ));
   }
 
   public function didExit() {
     if ($this->shouldSendExitEvent) {
       $this->dispatchEvent(self::EVENT_WILL_EXIT);
       $this->shouldSendExitEvent = false;
     }
 
     return $this;
   }
 
 }
diff --git a/src/infrastructure/daemon/PhutilDaemonOverseerModule.php b/src/infrastructure/daemon/PhutilDaemonOverseerModule.php
index 3e2cdaad3e..9c746d17a1 100644
--- a/src/infrastructure/daemon/PhutilDaemonOverseerModule.php
+++ b/src/infrastructure/daemon/PhutilDaemonOverseerModule.php
@@ -1,71 +1,71 @@
 <?php
 
 /**
  * Overseer modules allow daemons to be externally influenced.
  *
  * See @{class:PhabricatorDaemonOverseerModule} for a concrete example.
  */
 abstract class PhutilDaemonOverseerModule extends Phobject {
 
   private $throttles = array();
 
 
   /**
    * This method is used to indicate to the overseer that daemons should reload.
    *
    * @return bool  True if the daemons should reload, otherwise false.
    */
   public function shouldReloadDaemons() {
     return false;
   }
 
 
   /**
    * Should a hibernating daemon pool be awoken immediately?
    *
    * @return bool True to awaken the pool immediately.
    */
   public function shouldWakePool(PhutilDaemonPool $pool) {
     return false;
   }
 
 
   public static function getAllModules() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->execute();
   }
 
 
   /**
    * Throttle checks from executing too often.
    *
    * If you throttle a check like this, it will only execute once every 2.5
    * seconds:
    *
    *   if ($this->shouldThrottle('some.check', 2.5)) {
    *     return;
    *   }
    *
-   * @param string Throttle key.
-   * @param float Duration in seconds.
+   * @param string $name Throttle key.
+   * @param float $duration Duration in seconds.
    * @return bool True to throttle the check.
    */
   protected function shouldThrottle($name, $duration) {
     $throttle = idx($this->throttles, $name, 0);
     $now = microtime(true);
 
     // If not enough time has elapsed, throttle the check.
     $elapsed = ($now - $throttle);
     if ($elapsed < $duration) {
       return true;
     }
 
     // Otherwise, mark the current time as the last time we ran the check,
     // then let it continue.
     $this->throttles[$name] = $now;
 
     return false;
   }
 
 }
diff --git a/src/infrastructure/daemon/workers/PhabricatorTriggerDaemon.php b/src/infrastructure/daemon/workers/PhabricatorTriggerDaemon.php
index 9561f3d18a..736f17f81b 100644
--- a/src/infrastructure/daemon/workers/PhabricatorTriggerDaemon.php
+++ b/src/infrastructure/daemon/workers/PhabricatorTriggerDaemon.php
@@ -1,484 +1,484 @@
 <?php
 
 /**
  * Schedule and execute event triggers, which run code at specific times.
  *
  * Also performs garbage collection of old logs, caches, etc.
  *
  * @task garbage Garbage Collection
  */
 final class PhabricatorTriggerDaemon
   extends PhabricatorDaemon {
 
   const COUNTER_VERSION = 'trigger.version';
   const COUNTER_CURSOR = 'trigger.cursor';
 
   private $garbageCollectors;
   private $nextCollection;
 
   private $anyNuanceData;
   private $nuanceSources;
   private $nuanceCursors;
 
   private $calendarEngine;
 
   protected function run() {
 
     // The trigger daemon is a low-level infrastructure daemon which schedules
     // and executes chronological events. Examples include a subscription which
     // generates a bill on the 12th of every month, or a reminder email 15
     // minutes before a meeting.
 
     // Only one trigger daemon can run at a time, and very little work should
     // happen in the daemon process. In general, triggered events should
     // just schedule a task into the normal daemon worker queue and then
     // return. This allows the real work to take longer to execute without
     // disrupting other triggers.
 
     // The trigger mechanism guarantees that events will execute exactly once,
     // but does not guarantee that they will execute at precisely the specified
     // time. Under normal circumstances, they should execute within a minute or
     // so of the desired time, so this mechanism can be used for things like
     // meeting reminders.
 
     // If the trigger queue backs up (for example, because it is overwhelmed by
     // trigger updates, doesn't run for a while, or a trigger action is written
     // inefficiently) or the daemon queue backs up (usually for similar
     // reasons), events may execute an arbitrarily long time after they were
     // scheduled to execute. In some cases (like billing a subscription) this
     // may be desirable; in other cases (like sending a meeting reminder) the
     // action may want to check the current time and see if the event is still
     // relevant.
 
     // The trigger daemon works in two phases:
     //
     //   1. A scheduling phase processes recently updated triggers and
     //      schedules them for future execution. For example, this phase would
     //      see that a meeting trigger had been changed recently, determine
     //      when the reminder for it should execute, and then schedule the
     //      action to execute at that future date.
     //   2. An execution phase runs the actions for any scheduled events which
     //      are due to execute.
     //
     // The major goal of this design is to deliver on the guarantee that events
     // will execute exactly once. It prevents race conditions in scheduling
     // and execution by ensuring there is only one writer for either of these
     // phases. Without this separation of responsibilities, web processes
     // trying to reschedule events after an update could race with other web
     // processes or the daemon.
 
     // We want to start the first GC cycle right away, not wait 4 hours.
     $this->nextCollection = PhabricatorTime::getNow();
 
     do {
       PhabricatorCaches::destroyRequestCache();
 
       $lock = PhabricatorGlobalLock::newLock('trigger');
 
       try {
         $lock->lock(5);
       } catch (PhutilLockException $ex) {
         throw new PhutilProxyException(
           pht(
             'Another process is holding the trigger lock. Usually, this '.
             'means another copy of the trigger daemon is running elsewhere. '.
             'Multiple processes are not permitted to update triggers '.
             'simultaneously.'),
           $ex);
       }
 
       // Run the scheduling phase. This finds updated triggers which we have
       // not scheduled yet and schedules them.
       $last_version = $this->loadCurrentCursor();
       $head_version = $this->loadCurrentVersion();
 
       // The cursor points at the next record to process, so we can only skip
       // this step if we're ahead of the version number.
       if ($last_version <= $head_version) {
         $this->scheduleTriggers($last_version);
       }
 
       // Run the execution phase. This finds events which are due to execute
       // and runs them.
       $this->executeTriggers();
 
       $lock->unlock();
 
       $sleep_duration = $this->getSleepDuration();
       $sleep_duration = $this->runNuanceImportCursors($sleep_duration);
       $sleep_duration = $this->runGarbageCollection($sleep_duration);
       $sleep_duration = $this->runCalendarNotifier($sleep_duration);
 
       if ($this->shouldHibernate($sleep_duration)) {
         break;
       }
 
       $this->sleep($sleep_duration);
     } while (!$this->shouldExit());
   }
 
 
   /**
    * Process all of the triggers which have been updated since the last time
    * the daemon ran, scheduling them into the event table.
    *
-   * @param int Cursor for the next version update to process.
+   * @param int $cursor Cursor for the next version update to process.
    * @return void
    */
   private function scheduleTriggers($cursor) {
     $limit = 100;
 
     $query = id(new PhabricatorWorkerTriggerQuery())
       ->setViewer($this->getViewer())
       ->withVersionBetween($cursor, null)
       ->setOrder(PhabricatorWorkerTriggerQuery::ORDER_VERSION)
       ->needEvents(true)
       ->setLimit($limit);
     while (true) {
       $triggers = $query->execute();
 
       foreach ($triggers as $trigger) {
         $event = $trigger->getEvent();
         if ($event) {
           $last_epoch = $event->getLastEventEpoch();
         } else {
           $last_epoch = null;
         }
 
         $next_epoch = $trigger->getNextEventEpoch(
           $last_epoch,
           $is_reschedule = false);
 
         $new_event = PhabricatorWorkerTriggerEvent::initializeNewEvent($trigger)
           ->setLastEventEpoch($last_epoch)
           ->setNextEventEpoch($next_epoch);
 
         $new_event->openTransaction();
           if ($event) {
             $event->delete();
           }
 
           // Always save the new event. Note that we save it even if the next
           // epoch is `null`, indicating that it will never fire, because we
           // would lose the last epoch information if we delete it.
           //
           // In particular, some events may want to execute exactly once.
           // Retaining the last epoch allows them to do this, even if the
           // trigger is updated.
           $new_event->save();
 
           // Move the cursor forward to make sure we don't reprocess this
           // trigger until it is updated again.
           $this->updateCursor($trigger->getTriggerVersion() + 1);
         $new_event->saveTransaction();
       }
 
       // If we saw fewer than a full page of updated triggers, we're caught
       // up, so we can move on to the execution phase.
       if (count($triggers) < $limit) {
         break;
       }
 
       // Otherwise, skip past the stuff we just processed and grab another
       // page of updated triggers.
       $min = last($triggers)->getTriggerVersion() + 1;
       $query->withVersionBetween($min, null);
 
       $this->stillWorking();
     }
   }
 
 
   /**
    * Run scheduled event triggers which are due for execution.
    *
    * @return void
    */
   private function executeTriggers() {
 
     // We run only a limited number of triggers before ending the execution
     // phase. If we ran until exhaustion, we could end up executing very
     // out-of-date triggers if there was a long backlog: trigger changes
     // during this phase are not reflected in the event table until we run
     // another scheduling phase.
 
     // If we exit this phase with triggers still ready to execute we'll
     // jump back into the scheduling phase immediately, so this just makes
     // sure we don't spend an unreasonably long amount of time without
     // processing trigger updates and doing rescheduling.
 
     $limit = 100;
     $now = PhabricatorTime::getNow();
 
     $triggers = id(new PhabricatorWorkerTriggerQuery())
       ->setViewer($this->getViewer())
       ->setOrder(PhabricatorWorkerTriggerQuery::ORDER_EXECUTION)
       ->withNextEventBetween(null, $now)
       ->needEvents(true)
       ->setLimit($limit)
       ->execute();
     foreach ($triggers as $trigger) {
       $event = $trigger->getEvent();
 
       // Execute the trigger action.
       $trigger->executeTrigger(
         $event->getLastEventEpoch(),
         $event->getNextEventEpoch());
 
       // Now that we've executed the trigger, the current trigger epoch is
       // going to become the last epoch.
       $last_epoch = $event->getNextEventEpoch();
 
       // If this is a recurring trigger, give it an opportunity to reschedule.
       $reschedule_epoch = $trigger->getNextEventEpoch(
         $last_epoch,
         $is_reschedule = true);
 
       // Don't reschedule events unless the next occurrence is in the future.
       if (($reschedule_epoch !== null) &&
           ($last_epoch !== null) &&
           ($reschedule_epoch <= $last_epoch)) {
         throw new Exception(
           pht(
             'Trigger is attempting to perform a routine reschedule where '.
             'the next event (at %s) does not occur after the previous event '.
             '(at %s). Routine reschedules must strictly move event triggers '.
             'forward through time to avoid executing a trigger an infinite '.
             'number of times instantaneously.',
             $reschedule_epoch,
             $last_epoch));
       }
 
       $new_event = PhabricatorWorkerTriggerEvent::initializeNewEvent($trigger)
         ->setLastEventEpoch($last_epoch)
         ->setNextEventEpoch($reschedule_epoch);
 
       $event->openTransaction();
         // Remove the event we just processed.
         $event->delete();
 
         // See note in the scheduling phase about this; we save the new event
         // even if the next epoch is `null`.
         $new_event->save();
       $event->saveTransaction();
     }
   }
 
 
   /**
    * Get the number of seconds to sleep for before starting the next scheduling
    * phase.
    *
    * If no events are scheduled soon, we'll sleep briefly. Otherwise,
    * we'll sleep until the next scheduled event.
    *
    * @return int Number of seconds to sleep for.
    */
   private function getSleepDuration() {
     $sleep = phutil_units('3 minutes in seconds');
 
     $next_triggers = id(new PhabricatorWorkerTriggerQuery())
       ->setViewer($this->getViewer())
       ->setOrder(PhabricatorWorkerTriggerQuery::ORDER_EXECUTION)
       ->withNextEventBetween(0, null)
       ->setLimit(1)
       ->needEvents(true)
       ->execute();
     if ($next_triggers) {
       $next_trigger = head($next_triggers);
       $next_epoch = $next_trigger->getEvent()->getNextEventEpoch();
       $until = max(0, $next_epoch - PhabricatorTime::getNow());
       $sleep = min($sleep, $until);
     }
 
     return $sleep;
   }
 
 
 /* -(  Counters  )----------------------------------------------------------- */
 
 
   private function loadCurrentCursor() {
     return $this->loadCurrentCounter(self::COUNTER_CURSOR);
   }
 
   private function loadCurrentVersion() {
     return $this->loadCurrentCounter(self::COUNTER_VERSION);
   }
 
   private function updateCursor($value) {
     LiskDAO::overwriteCounterValue(
       id(new PhabricatorWorkerTrigger())->establishConnection('w'),
       self::COUNTER_CURSOR,
       $value);
   }
 
   private function loadCurrentCounter($counter_name) {
     return (int)LiskDAO::loadCurrentCounterValue(
       id(new PhabricatorWorkerTrigger())->establishConnection('w'),
       $counter_name);
   }
 
 
 /* -(  Garbage Collection  )------------------------------------------------- */
 
 
   /**
    * Run the garbage collector for up to a specified number of seconds.
    *
-   * @param int Number of seconds the GC may run for.
+   * @param int $duration Number of seconds the GC may run for.
    * @return int Number of seconds remaining in the time budget.
    * @task garbage
    */
   private function runGarbageCollection($duration) {
     $run_until = (PhabricatorTime::getNow() + $duration);
 
     // NOTE: We always run at least one GC cycle to make sure the GC can make
     // progress even if the trigger queue is busy.
     do {
       $more_garbage = $this->updateGarbageCollection();
       if (!$more_garbage) {
         // If we don't have any more collection work to perform, we're all
         // done.
         break;
       }
     } while (PhabricatorTime::getNow() <= $run_until);
 
     $remaining = max(0, $run_until - PhabricatorTime::getNow());
 
     return $remaining;
   }
 
 
   /**
    * Update garbage collection, possibly collecting a small amount of garbage.
    *
    * @return bool True if there is more garbage to collect.
    * @task garbage
    */
   private function updateGarbageCollection() {
     // If we're ready to start the next collection cycle, load all the
     // collectors.
     $next = $this->nextCollection;
     if ($next && (PhabricatorTime::getNow() >= $next)) {
       $this->nextCollection = null;
 
       $all_collectors = PhabricatorGarbageCollector::getAllCollectors();
       $this->garbageCollectors = $all_collectors;
     }
 
     // If we're in a collection cycle, continue collection.
     if ($this->garbageCollectors) {
       foreach ($this->garbageCollectors as $key => $collector) {
         $more_garbage = $collector->runCollector();
         if (!$more_garbage) {
           unset($this->garbageCollectors[$key]);
         }
         // We only run one collection per call, to prevent triggers from being
         // thrown too far off schedule if there's a lot of garbage to collect.
         break;
       }
 
       if ($this->garbageCollectors) {
         // If we have more work to do, return true.
         return true;
       }
 
       // Otherwise, reschedule another cycle in 4 hours.
       $now = PhabricatorTime::getNow();
       $wait = phutil_units('4 hours in seconds');
       $this->nextCollection = $now + $wait;
     }
 
     return false;
   }
 
 
 /* -(  Nuance Importers  )--------------------------------------------------- */
 
 
   private function runNuanceImportCursors($duration) {
     $run_until = (PhabricatorTime::getNow() + $duration);
 
     do {
       $more_data = $this->updateNuanceImportCursors();
       if (!$more_data) {
         break;
       }
     } while (PhabricatorTime::getNow() <= $run_until);
 
     $remaining = max(0, $run_until - PhabricatorTime::getNow());
 
     return $remaining;
   }
 
 
   private function updateNuanceImportCursors() {
     $nuance_app = 'PhabricatorNuanceApplication';
     if (!PhabricatorApplication::isClassInstalled($nuance_app)) {
       return false;
     }
 
     // If we haven't loaded sources yet, load them first.
     if (!$this->nuanceSources && !$this->nuanceCursors) {
       $this->anyNuanceData = false;
 
       $sources = id(new NuanceSourceQuery())
         ->setViewer($this->getViewer())
         ->withIsDisabled(false)
         ->withHasImportCursors(true)
         ->execute();
       if (!$sources) {
         return false;
       }
 
       $this->nuanceSources = array_reverse($sources);
     }
 
     // If we don't have any cursors, move to the next source and generate its
     // cursors.
     if (!$this->nuanceCursors) {
       $source = array_pop($this->nuanceSources);
 
       $definition = $source->getDefinition()
         ->setViewer($this->getViewer())
         ->setSource($source);
 
       $cursors = $definition->getImportCursors();
       $this->nuanceCursors = array_reverse($cursors);
     }
 
     // Update the next cursor.
     $cursor = array_pop($this->nuanceCursors);
     if ($cursor) {
       $more_data = $cursor->importFromSource();
       if ($more_data) {
         $this->anyNuanceData = true;
       }
     }
 
     if (!$this->nuanceSources && !$this->nuanceCursors) {
       return $this->anyNuanceData;
     }
 
     return true;
   }
 
 
 /* -(  Calendar Notifier  )-------------------------------------------------- */
 
 
   private function runCalendarNotifier($duration) {
     $run_until = (PhabricatorTime::getNow() + $duration);
 
     if (!$this->calendarEngine) {
       $this->calendarEngine = new PhabricatorCalendarNotificationEngine();
     }
 
     $this->calendarEngine->publishNotifications();
 
     $remaining = max(0, $run_until - PhabricatorTime::getNow());
     return $remaining;
   }
 
 }
diff --git a/src/infrastructure/daemon/workers/PhabricatorWorker.php b/src/infrastructure/daemon/workers/PhabricatorWorker.php
index 2b1e650485..69815557ec 100644
--- a/src/infrastructure/daemon/workers/PhabricatorWorker.php
+++ b/src/infrastructure/daemon/workers/PhabricatorWorker.php
@@ -1,328 +1,328 @@
 <?php
 
 /**
  * @task config   Configuring Retries and Failures
  */
 abstract class PhabricatorWorker extends Phobject {
 
   private $data;
   private static $runAllTasksInProcess = false;
   private $queuedTasks = array();
   private $currentWorkerTask;
 
   // NOTE: Lower priority numbers execute first. The priority numbers have to
   // have the same ordering that IDs do (lowest first) so MySQL can use a
   // multipart key across both of them efficiently.
 
   const PRIORITY_ALERTS  = 1000;
   const PRIORITY_DEFAULT = 2000;
   const PRIORITY_COMMIT  = 2500;
   const PRIORITY_BULK    = 3000;
   const PRIORITY_INDEX   = 3500;
   const PRIORITY_IMPORT  = 4000;
 
   /**
    * Special owner indicating that the task has yielded.
    */
   const YIELD_OWNER = '(yield)';
 
 /* -(  Configuring Retries and Failures  )----------------------------------- */
 
 
   /**
    * Return the number of seconds this worker needs hold a lease on the task for
    * while it performs work. For most tasks you can leave this at `null`, which
    * will give you a default lease (currently 2 hours).
    *
    * For tasks which may take a very long time to complete, you should return
    * an upper bound on the amount of time the task may require.
    *
    * @return int|null  Number of seconds this task needs to remain leased for,
    *                   or null for a default lease.
    *
    * @task config
    */
   public function getRequiredLeaseTime() {
     return null;
   }
 
 
   /**
    * Return the maximum number of times this task may be retried before it is
    * considered permanently failed. By default, tasks retry indefinitely. You
    * can throw a @{class:PhabricatorWorkerPermanentFailureException} to cause an
    * immediate permanent failure.
    *
    * @return int|null  Number of times the task will retry before permanent
    *                   failure. Return `null` to retry indefinitely.
    *
    * @task config
    */
   public function getMaximumRetryCount() {
     return null;
   }
 
 
   /**
    * Return the number of seconds a task should wait after a failure before
    * retrying. For most tasks you can leave this at `null`, which will give you
    * a short default retry period (currently 60 seconds).
    *
-   * @param  PhabricatorWorkerTask  The task itself. This object is probably
-   *                                useful mostly to examine the failure count
-   *                                if you want to implement staggered retries,
-   *                                or to examine the execution exception if
-   *                                you want to react to different failures in
-   *                                different ways.
+   * @param  PhabricatorWorkerTask  $task The task itself. This object is
+   *                                probably useful mostly to examine the
+   *                                failure count if you want to implement
+   *                                staggered retries, or to examine the
+   *                                execution exception if you want to react to
+   *                                different failures in different ways.
    * @return int|null               Number of seconds to wait between retries,
    *                                or null for a default retry period
    *                                (currently 60 seconds).
    *
    * @task config
    */
   public function getWaitBeforeRetry(PhabricatorWorkerTask $task) {
     return null;
   }
 
   public function setCurrentWorkerTask(PhabricatorWorkerTask $task) {
     $this->currentWorkerTask = $task;
     return $this;
   }
 
   public function getCurrentWorkerTask() {
     return $this->currentWorkerTask;
   }
 
   public function getCurrentWorkerTaskID() {
     $task = $this->getCurrentWorkerTask();
     if (!$task) {
       return null;
     }
     return $task->getID();
   }
 
   abstract protected function doWork();
 
   final public function __construct($data) {
     $this->data = $data;
   }
 
   final protected function getTaskData() {
     return $this->data;
   }
 
   final protected function getTaskDataValue($key, $default = null) {
     $data = $this->getTaskData();
     if (!is_array($data)) {
       throw new PhabricatorWorkerPermanentFailureException(
         pht('Expected task data to be a dictionary.'));
     }
     return idx($data, $key, $default);
   }
 
   final public function executeTask() {
     $this->doWork();
   }
 
   final public static function scheduleTask(
     $task_class,
     $data,
     $options = array()) {
 
     PhutilTypeSpec::checkMap(
       $options,
       array(
         'priority' => 'optional int|null',
         'objectPHID' => 'optional string|null',
         'containerPHID' => 'optional string|null',
         'delayUntil' => 'optional int|null',
       ));
 
     $priority = idx($options, 'priority');
     if ($priority === null) {
       $priority = self::PRIORITY_DEFAULT;
     }
     $object_phid = idx($options, 'objectPHID');
     $container_phid = idx($options, 'containerPHID');
 
     $task = id(new PhabricatorWorkerActiveTask())
       ->setTaskClass($task_class)
       ->setData($data)
       ->setPriority($priority)
       ->setObjectPHID($object_phid)
       ->setContainerPHID($container_phid);
 
     $delay = idx($options, 'delayUntil');
     if ($delay) {
       $task->setLeaseExpires($delay);
     }
 
     if (self::$runAllTasksInProcess) {
       // Do the work in-process.
       $worker = newv($task_class, array($data));
 
       while (true) {
         try {
           $worker->executeTask();
           $worker->flushTaskQueue();
 
           $task_result = PhabricatorWorkerArchiveTask::RESULT_SUCCESS;
           break;
         } catch (PhabricatorWorkerPermanentFailureException $ex) {
           $proxy = new PhutilProxyException(
             pht(
               'In-process task ("%s") failed permanently.',
               $task_class),
             $ex);
 
           phlog($proxy);
 
           $task_result = PhabricatorWorkerArchiveTask::RESULT_FAILURE;
           break;
         } catch (PhabricatorWorkerYieldException $ex) {
           phlog(
             pht(
               'In-process task "%s" yielded for %s seconds, sleeping...',
               $task_class,
               $ex->getDuration()));
           sleep($ex->getDuration());
         }
       }
 
       // Now, save a task row and immediately archive it so we can return an
       // object with a valid ID.
       $task->openTransaction();
         $task->save();
         $archived = $task->archiveTask($task_result, 0);
       $task->saveTransaction();
 
       return $archived;
     } else {
       $task->save();
       return $task;
     }
   }
 
 
   public function renderForDisplay(PhabricatorUser $viewer) {
     return null;
   }
 
   /**
    * Set this flag to execute scheduled tasks synchronously, in the same
    * process. This is useful for debugging, and otherwise dramatically worse
    * in every way imaginable.
    */
   public static function setRunAllTasksInProcess($all) {
     self::$runAllTasksInProcess = $all;
   }
 
   final protected function log($pattern /* , ... */) {
     $console = PhutilConsole::getConsole();
     $argv = func_get_args();
     call_user_func_array(array($console, 'writeLog'), $argv);
     return $this;
   }
 
 
   /**
    * Queue a task to be executed after this one succeeds.
    *
    * The followup task will be queued only if this task completes cleanly.
    *
-   * @param string    Task class to queue.
-   * @param array     Data for the followup task.
-   * @param array Options for the followup task.
+   * @param string    $class Task class to queue.
+   * @param array     $data Data for the followup task.
+   * @param array?    $options Options for the followup task.
    * @return this
    */
   final protected function queueTask(
     $class,
     array $data,
     array $options = array()) {
     $this->queuedTasks[] = array($class, $data, $options);
     return $this;
   }
 
 
   /**
    * Get tasks queued as followups by @{method:queueTask}.
    *
    * @return list<tuple<string, wild, int|null>> Queued task specifications.
    */
   final protected function getQueuedTasks() {
     return $this->queuedTasks;
   }
 
 
   /**
    * Schedule any queued tasks, then empty the task queue.
    *
    * By default, the queue is flushed only if a task succeeds. You can call
    * this method to force the queue to flush before failing (for example, if
    * you are using queues to improve locking behavior).
    *
-   * @param map<string, wild> Optional default options.
+   * @param map<string, wild>? $defaults Optional default options.
    */
   final public function flushTaskQueue($defaults = array()) {
     foreach ($this->getQueuedTasks() as $task) {
       list($class, $data, $options) = $task;
 
       $options = $options + $defaults;
 
       self::scheduleTask($class, $data, $options);
     }
 
     $this->queuedTasks = array();
   }
 
 
   /**
    * Awaken tasks that have yielded.
    *
    * Reschedules the specified tasks if they are currently queued in a yielded,
    * unleased, unretried state so they'll execute sooner. This can let the
    * queue avoid unnecessary waits.
    *
    * This method does not provide any assurances about when these tasks will
    * execute, or even guarantee that it will have any effect at all.
    *
-   * @param list<id> List of task IDs to try to awaken.
+   * @param list<id> $ids List of task IDs to try to awaken.
    * @return void
    */
   final public static function awakenTaskIDs(array $ids) {
     if (!$ids) {
       return;
     }
 
     $table = new PhabricatorWorkerActiveTask();
     $conn_w = $table->establishConnection('w');
 
     // NOTE: At least for now, we're keeping these tasks yielded, just
     // pretending that they threw a shorter yield than they really did.
 
     // Overlap the windows here to handle minor client/server time differences
     // and because it's likely correct to push these tasks to the head of their
     // respective priorities. There is a good chance they are ready to execute.
     $window = phutil_units('1 hour in seconds');
     $epoch_ago = (PhabricatorTime::getNow() - $window);
 
     queryfx(
       $conn_w,
       'UPDATE %T SET leaseExpires = %d
         WHERE id IN (%Ld)
           AND leaseOwner = %s
           AND leaseExpires > %d
           AND failureCount = 0',
       $table->getTableName(),
       $epoch_ago,
       $ids,
       self::YIELD_OWNER,
       $epoch_ago);
   }
 
   protected function newContentSource() {
     return PhabricatorContentSource::newForSource(
       PhabricatorDaemonContentSource::SOURCECONST);
   }
 
 }
diff --git a/src/infrastructure/daemon/workers/action/PhabricatorTriggerAction.php b/src/infrastructure/daemon/workers/action/PhabricatorTriggerAction.php
index 2390a284ba..329c75dfc6 100644
--- a/src/infrastructure/daemon/workers/action/PhabricatorTriggerAction.php
+++ b/src/infrastructure/daemon/workers/action/PhabricatorTriggerAction.php
@@ -1,73 +1,73 @@
 <?php
 
 /**
  * A trigger action reacts to a scheduled event.
  *
  * Almost all events should use a @{class:PhabricatorScheduleTaskTriggerAction}.
  * Avoid introducing new actions without strong justification. See that class
  * for discussion of concerns.
  */
 abstract class PhabricatorTriggerAction extends Phobject {
 
   private $properties;
 
   public function __construct(array $properties) {
     $this->validateProperties($properties);
     $this->properties = $properties;
   }
 
   public function getProperties() {
     return $this->properties;
   }
 
   public function getProperty($key, $default = null) {
     return idx($this->properties, $key, $default);
   }
 
 
   /**
    * Validate action configuration.
    *
-   * @param map<string, wild> Map of action properties.
+   * @param map<string, wild> $properties Map of action properties.
    * @return void
    */
   abstract public function validateProperties(array $properties);
 
 
   /**
    * Execute this action.
    *
    * IMPORTANT: Trigger actions must execute quickly!
    *
    * In most cases, trigger actions should queue a worker task and then exit.
    * The actual trigger execution occurs in a locked section in the trigger
    * daemon and blocks all other triggers. By queueing a task instead of
    * performing processing directly, triggers can execute more involved actions
    * without blocking other triggers.
    *
    * Almost all events should use @{class:PhabricatorScheduleTaskTriggerAction}
    * to do this, ensuring that they execute quickly.
    *
    * An action may trigger a long time after it is scheduled. For example,
    * a meeting reminder may be scheduled at 9:45 AM, but the action may not
    * execute until later (for example, because the server was down for
    * maintenance). You can detect cases like this by comparing `$this_epoch`
    * (which holds the time the event was scheduled to execute at) to
    * `PhabricatorTime::getNow()` (which returns the current time). In the
    * case of a meeting reminder, you may want to ignore the action if it
    * executes too late to be useful (for example, after a meeting is over).
    *
    * Because actions should normally queue a task and there may be a second,
    * arbitrarily long delay between trigger execution and task execution, it
    * may be simplest to pass the trigger time to the task and then make the
    * decision to discard the action there.
    *
-   * @param int|null Last time the event occurred, or null if it has never
-   *   triggered before.
-   * @param int The scheduled time for the current action. This may be
-   *   significantly different from the current time.
+   * @param int|null $last_epoch Last time the event occurred, or null if it
+   *   has never triggered before.
+   * @param int $this_epoch The scheduled time for the current action. This
+   *   may be significantly different from the current time.
    * @return void
    */
   abstract public function execute($last_epoch, $this_epoch);
 
 }
diff --git a/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php b/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php
index 400e6fad8c..b33ea0ade3 100644
--- a/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php
+++ b/src/infrastructure/daemon/workers/clock/PhabricatorTriggerClock.php
@@ -1,74 +1,75 @@
 <?php
 
 /**
  * A trigger clock implements scheduling rules for an event.
  *
  * Two examples of triggered events are a subscription which bills on the 12th
  * of every month, or a meeting reminder which sends an email 15 minutes before
  * an event. A trigger clock contains the logic to figure out exactly when
  * those times are.
  *
  * For example, it might schedule an event every hour, or every Thursday, or on
  * the 15th of every month at 3PM, or only at a specific time.
  */
 abstract class PhabricatorTriggerClock extends Phobject {
 
   private $properties;
 
   public function __construct(array $properties) {
     $this->validateProperties($properties);
     $this->properties = $properties;
   }
 
   public function getProperties() {
     return $this->properties;
   }
 
   public function getProperty($key, $default = null) {
     return idx($this->properties, $key, $default);
   }
 
 
   /**
    * Validate clock configuration.
    *
-   * @param map<string, wild> Map of clock properties.
+   * @param map<string, wild> $properties Map of clock properties.
    * @return void
    */
   abstract public function validateProperties(array $properties);
 
 
   /**
    * Get the next occurrence of this event.
    *
    * This method takes two parameters: the last time this event occurred (or
    * null if it has never triggered before) and a flag distinguishing between
    * a normal reschedule (after a successful trigger) or an update because of
    * a trigger change.
    *
    * If this event does not occur again, return `null` to stop it from being
    * rescheduled. For example, a meeting reminder may be sent only once before
    * the meeting.
    *
    * If this event does occur again, return the epoch timestamp of the next
    * occurrence.
    *
    * When performing routine reschedules, the event must move forward in time:
    * any timestamp you return must be later than the last event. For instance,
    * if this event triggers an invoice, the next invoice date must be after
    * the previous invoice date. This prevents an event from looping more than
    * once per second.
    *
    * In contrast, after an update (not a routine reschedule), the next event
    * may be scheduled at any time. For example, if a meeting is moved from next
    * week to 3 minutes from now, the clock may reschedule the notification to
    * occur 12 minutes ago. This will cause it to execute immediately.
    *
-   * @param int|null Last time the event occurred, or null if it has never
-   *   triggered before.
-   * @param bool True if this is a reschedule after a successful trigger.
+   * @param int|null $last_epoch Last time the event occurred, or null if it
+   *   has never triggered before.
+   * @param bool $is_reschedule True if this is a reschedule after a successful
+   *   trigger.
    * @return int|null Next event, or null to decline to reschedule.
    */
   abstract public function getNextEventEpoch($last_epoch, $is_reschedule);
 
 }
diff --git a/src/infrastructure/daemon/workers/query/PhabricatorWorkerLeaseQuery.php b/src/infrastructure/daemon/workers/query/PhabricatorWorkerLeaseQuery.php
index 0163143ae7..697d89a168 100644
--- a/src/infrastructure/daemon/workers/query/PhabricatorWorkerLeaseQuery.php
+++ b/src/infrastructure/daemon/workers/query/PhabricatorWorkerLeaseQuery.php
@@ -1,345 +1,345 @@
 <?php
 
 /**
  * Select and lease tasks from the worker task queue.
  */
 final class PhabricatorWorkerLeaseQuery extends PhabricatorQuery {
 
   const PHASE_LEASED = 'leased';
   const PHASE_UNLEASED = 'unleased';
   const PHASE_EXPIRED  = 'expired';
 
   private $ids;
   private $objectPHIDs;
   private $limit;
   private $skipLease;
   private $leased = false;
 
   public static function getDefaultWaitBeforeRetry() {
     return phutil_units('5 minutes in seconds');
   }
 
   public static function getDefaultLeaseDuration() {
     return phutil_units('2 hours in seconds');
   }
 
   /**
    * Set this flag to select tasks from the top of the queue without leasing
    * them.
    *
    * This can be used to show which tasks are coming up next without altering
    * the queue's behavior.
    *
-   * @param bool True to skip the lease acquisition step.
+   * @param bool $skip True to skip the lease acquisition step.
    */
   public function setSkipLease($skip) {
     $this->skipLease = $skip;
     return $this;
   }
 
   public function withIDs(array $ids) {
     $this->ids = $ids;
     return $this;
   }
 
   public function withObjectPHIDs(array $phids) {
     $this->objectPHIDs = $phids;
     return $this;
   }
 
   /**
    * Select only leased tasks, only unleased tasks, or both types of task.
    *
    * By default, queries select only unleased tasks (equivalent to passing
    * `false` to this method). You can pass `true` to select only leased tasks,
    * or `null` to ignore the lease status of tasks.
    *
    * If your result set potentially includes leased tasks, you must disable
    * leasing using @{method:setSkipLease}. These options are intended for use
    * when displaying task status information.
    *
-   * @param mixed `true` to select only leased tasks, `false` to select only
-   *              unleased tasks (default), or `null` to select both.
+   * @param mixed $leased `true` to select only leased tasks, `false` to select
+   *              only unleased tasks (default), or `null` to select both.
    * @return this
    */
   public function withLeasedTasks($leased) {
     $this->leased = $leased;
     return $this;
   }
 
   public function setLimit($limit) {
     $this->limit = $limit;
     return $this;
   }
 
   public function execute() {
     if (!$this->limit) {
       throw new Exception(
         pht('You must %s when leasing tasks.', 'setLimit()'));
     }
 
     if ($this->leased !== false) {
       if (!$this->skipLease) {
         throw new Exception(
           pht(
             'If you potentially select leased tasks using %s, '.
             'you MUST disable lease acquisition by calling %s.',
             'withLeasedTasks()',
             'setSkipLease()'));
       }
     }
 
     $task_table = new PhabricatorWorkerActiveTask();
     $taskdata_table = new PhabricatorWorkerTaskData();
     $lease_ownership_name = $this->getLeaseOwnershipName();
 
     $conn_w = $task_table->establishConnection('w');
 
     // Try to satisfy the request from new, unleased tasks first. If we don't
     // find enough tasks, try tasks with expired leases (i.e., tasks which have
     // previously failed).
 
     // If we're selecting leased tasks, look for them first.
 
     $phases = array();
     if ($this->leased !== false) {
       $phases[] = self::PHASE_LEASED;
     }
     if ($this->leased !== true) {
       $phases[] = self::PHASE_UNLEASED;
       $phases[] = self::PHASE_EXPIRED;
     }
     $limit = $this->limit;
 
     $leased = 0;
     $task_ids = array();
     foreach ($phases as $phase) {
       // NOTE: If we issue `UPDATE ... WHERE ... ORDER BY id ASC`, the query
       // goes very, very slowly. The `ORDER BY` triggers this, although we get
       // the same apparent results without it. Without the ORDER BY, binary
       // read slaves complain that the query isn't repeatable. To avoid both
       // problems, do a SELECT and then an UPDATE.
 
       $rows = queryfx_all(
         $conn_w,
         'SELECT id, leaseOwner FROM %T %Q %Q %Q',
         $task_table->getTableName(),
         $this->buildCustomWhereClause($conn_w, $phase),
         $this->buildOrderClause($conn_w, $phase),
         $this->buildLimitClause($conn_w, $limit - $leased));
 
       // NOTE: Sometimes, we'll race with another worker and they'll grab
       // this task before we do. We could reduce how often this happens by
       // selecting more tasks than we need, then shuffling them and trying
       // to lock only the number we're actually after. However, the amount
       // of time workers spend here should be very small relative to their
       // total runtime, so keep it simple for the moment.
 
       if ($rows) {
         if ($this->skipLease) {
           $leased += count($rows);
           $task_ids += array_fuse(ipull($rows, 'id'));
         } else {
           queryfx(
             $conn_w,
             'UPDATE %T task
               SET leaseOwner = %s, leaseExpires = UNIX_TIMESTAMP() + %d
               %Q',
             $task_table->getTableName(),
             $lease_ownership_name,
             self::getDefaultLeaseDuration(),
             $this->buildUpdateWhereClause($conn_w, $phase, $rows));
 
           $leased += $conn_w->getAffectedRows();
         }
 
         if ($leased == $limit) {
           break;
         }
       }
     }
 
     if (!$leased) {
       return array();
     }
 
     if ($this->skipLease) {
       $selection_condition = qsprintf(
         $conn_w,
         'task.id IN (%Ld)',
         $task_ids);
     } else {
       $selection_condition = qsprintf(
         $conn_w,
         'task.leaseOwner = %s AND leaseExpires > UNIX_TIMESTAMP()',
         $lease_ownership_name);
     }
 
     $data = queryfx_all(
       $conn_w,
       'SELECT task.*, taskdata.data _taskData, UNIX_TIMESTAMP() _serverTime
         FROM %T task LEFT JOIN %T taskdata
           ON taskdata.id = task.dataID
         WHERE %Q %Q %Q',
       $task_table->getTableName(),
       $taskdata_table->getTableName(),
       $selection_condition,
       $this->buildOrderClause($conn_w, $phase),
       $this->buildLimitClause($conn_w, $limit));
 
     $tasks = $task_table->loadAllFromArray($data);
     $tasks = mpull($tasks, null, 'getID');
 
     foreach ($data as $row) {
       $tasks[$row['id']]->setServerTime($row['_serverTime']);
       if ($row['_taskData']) {
         $task_data = json_decode($row['_taskData'], true);
       } else {
         $task_data = null;
       }
       $tasks[$row['id']]->setData($task_data);
     }
 
     if ($this->skipLease) {
       // Reorder rows into the original phase order if this is a status query.
       $tasks = array_select_keys($tasks, $task_ids);
     }
 
     return $tasks;
   }
 
   protected function buildCustomWhereClause(
     AphrontDatabaseConnection $conn,
     $phase) {
 
     $where = array();
 
     switch ($phase) {
       case self::PHASE_LEASED:
         $where[] = qsprintf(
           $conn,
           'leaseOwner IS NOT NULL');
         $where[] = qsprintf(
           $conn,
           'leaseExpires >= UNIX_TIMESTAMP()');
         break;
       case self::PHASE_UNLEASED:
         $where[] = qsprintf(
           $conn,
           'leaseOwner IS NULL');
         break;
       case self::PHASE_EXPIRED:
         $where[] = qsprintf(
           $conn,
           'leaseExpires < UNIX_TIMESTAMP()');
         break;
       default:
         throw new Exception(pht("Unknown phase '%s'!", $phase));
     }
 
     if ($this->ids !== null) {
       $where[] = qsprintf($conn, 'id IN (%Ld)', $this->ids);
     }
 
     if ($this->objectPHIDs !== null) {
       $where[] = qsprintf($conn, 'objectPHID IN (%Ls)', $this->objectPHIDs);
     }
 
     return $this->formatWhereClause($conn, $where);
   }
 
   private function buildUpdateWhereClause(
     AphrontDatabaseConnection $conn,
     $phase,
     array $rows) {
 
     $where = array();
 
     // NOTE: This is basically working around the MySQL behavior that
     // `IN (NULL)` doesn't match NULL.
 
     switch ($phase) {
       case self::PHASE_LEASED:
         throw new Exception(
           pht(
             'Trying to lease tasks selected in the leased phase! This is '.
             'intended to be impossible.'));
       case self::PHASE_UNLEASED:
         $where[] = qsprintf($conn, 'leaseOwner IS NULL');
         $where[] = qsprintf($conn, 'id IN (%Ld)', ipull($rows, 'id'));
         break;
       case self::PHASE_EXPIRED:
         $in = array();
         foreach ($rows as $row) {
           $in[] = qsprintf(
             $conn,
             '(id = %d AND leaseOwner = %s)',
             $row['id'],
             $row['leaseOwner']);
         }
         $where[] = qsprintf($conn, '%LO', $in);
         break;
       default:
         throw new Exception(pht('Unknown phase "%s"!', $phase));
     }
 
     return $this->formatWhereClause($conn, $where);
   }
 
   private function buildOrderClause(AphrontDatabaseConnection $conn_w, $phase) {
     switch ($phase) {
       case self::PHASE_LEASED:
         // Ideally we'd probably order these by lease acquisition time, but
         // we don't have that handy and this is a good approximation.
         return qsprintf($conn_w, 'ORDER BY priority ASC, id ASC');
       case self::PHASE_UNLEASED:
         // When selecting new tasks, we want to consume them in order of
         // increasing priority (and then FIFO).
         return qsprintf($conn_w, 'ORDER BY priority ASC, id ASC');
       case self::PHASE_EXPIRED:
         // When selecting failed tasks, we want to consume them in roughly
         // FIFO order of their failures, which is not necessarily their original
         // queue order.
 
         // Particularly, this is important for tasks which use soft failures to
         // indicate that they are waiting on other tasks to complete: we need to
         // push them to the end of the queue after they fail, at least on
         // average, so we don't deadlock retrying the same blocked task over
         // and over again.
         return qsprintf($conn_w, 'ORDER BY leaseExpires ASC');
       default:
         throw new Exception(pht('Unknown phase "%s"!', $phase));
     }
   }
 
   private function buildLimitClause(AphrontDatabaseConnection $conn_w, $limit) {
     return qsprintf($conn_w, 'LIMIT %d', $limit);
   }
 
   private function getLeaseOwnershipName() {
     static $sequence = 0;
 
     // TODO: If the host name is very long, this can overflow the 64-character
     // column, so we pick just the first part of the host name. It might be
     // useful to just use a random hash as the identifier instead and put the
     // pid / time / host (which are somewhat useful diagnostically) elsewhere.
     // Likely, we could store a daemon ID instead and use that to identify
     // when and where code executed. See T6742.
 
     $host = php_uname('n');
     $host = id(new PhutilUTF8StringTruncator())
       ->setMaximumBytes(32)
       ->setTerminator('...')
       ->truncateString($host);
 
     $parts = array(
       getmypid(),
       time(),
       $host,
       ++$sequence,
     );
 
     return implode(':', $parts);
   }
 
 }
diff --git a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php
index 8ca12d60e4..fa2f04f5a1 100644
--- a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php
+++ b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php
@@ -1,241 +1,241 @@
 <?php
 
 final class PhabricatorWorkerTriggerQuery
   extends PhabricatorPolicyAwareQuery {
 
   // NOTE: This is a PolicyAware query so it can work with other infrastructure
   // like handles; triggers themselves are low-level and do not have
   // meaningful policies.
 
   const ORDER_ID = 'id';
   const ORDER_EXECUTION = 'execution';
   const ORDER_VERSION = 'version';
 
   private $ids;
   private $phids;
   private $versionMin;
   private $versionMax;
   private $nextEpochMin;
   private $nextEpochMax;
 
   private $needEvents;
   private $order = self::ORDER_ID;
 
   public function getQueryApplicationClass() {
     return null;
   }
 
   public function withIDs(array $ids) {
     $this->ids = $ids;
     return $this;
   }
 
   public function withPHIDs(array $phids) {
     $this->phids = $phids;
     return $this;
   }
 
   public function withVersionBetween($min, $max) {
     $this->versionMin = $min;
     $this->versionMax = $max;
     return $this;
   }
 
   public function withNextEventBetween($min, $max) {
     $this->nextEpochMin = $min;
     $this->nextEpochMax = $max;
     return $this;
   }
 
   public function needEvents($need_events) {
     $this->needEvents = $need_events;
     return $this;
   }
 
   /**
    * Set the result order.
    *
    * Note that using `ORDER_EXECUTION` will also filter results to include only
    * triggers which have been scheduled to execute. You should not use this
    * ordering when querying for specific triggers, e.g. by ID or PHID.
    *
-   * @param const Result order.
+   * @param const $order Result order.
    * @return this
    */
   public function setOrder($order) {
     $this->order = $order;
     return $this;
   }
 
   protected function nextPage(array $page) {
     // NOTE: We don't implement paging because we don't currently ever need
     // it and paging ORDER_EXECUTION is a hassle.
 
     // (Before T13266, we raised an exception here, but since "nextPage()" is
     // now called even if we don't page we can't do that anymore. Just do
     // nothing instead.)
     return null;
   }
 
   protected function loadPage() {
     $task_table = new PhabricatorWorkerTrigger();
 
     $conn_r = $task_table->establishConnection('r');
 
     $rows = queryfx_all(
       $conn_r,
       'SELECT t.* FROM %T t %Q %Q %Q %Q',
       $task_table->getTableName(),
       $this->buildJoinClause($conn_r),
       $this->buildWhereClause($conn_r),
       $this->buildOrderClause($conn_r),
       $this->buildLimitClause($conn_r));
 
     $triggers = $task_table->loadAllFromArray($rows);
 
     if ($triggers) {
       if ($this->needEvents) {
         $ids = mpull($triggers, 'getID');
 
         $events = id(new PhabricatorWorkerTriggerEvent())->loadAllWhere(
           'triggerID IN (%Ld)',
           $ids);
         $events = mpull($events, null, 'getTriggerID');
 
         foreach ($triggers as $key => $trigger) {
           $event = idx($events, $trigger->getID());
           $trigger->attachEvent($event);
         }
       }
 
       foreach ($triggers as $key => $trigger) {
         $clock_class = $trigger->getClockClass();
         if (!is_subclass_of($clock_class, 'PhabricatorTriggerClock')) {
           unset($triggers[$key]);
           continue;
         }
 
         try {
           $argv = array($trigger->getClockProperties());
           $clock = newv($clock_class, $argv);
         } catch (Exception $ex) {
           unset($triggers[$key]);
           continue;
         }
 
         $trigger->attachClock($clock);
       }
 
 
       foreach ($triggers as $key => $trigger) {
         $action_class = $trigger->getActionClass();
         if (!is_subclass_of($action_class, 'PhabricatorTriggerAction')) {
           unset($triggers[$key]);
           continue;
         }
 
         try {
           $argv = array($trigger->getActionProperties());
           $action = newv($action_class, $argv);
         } catch (Exception $ex) {
           unset($triggers[$key]);
           continue;
         }
 
         $trigger->attachAction($action);
       }
     }
 
     return $triggers;
   }
 
   protected function buildJoinClause(AphrontDatabaseConnection $conn) {
     $joins = array();
 
     if (($this->nextEpochMin !== null) ||
         ($this->nextEpochMax !== null) ||
         ($this->order == self::ORDER_EXECUTION)) {
       $joins[] = qsprintf(
         $conn,
         'JOIN %T e ON e.triggerID = t.id',
         id(new PhabricatorWorkerTriggerEvent())->getTableName());
     }
 
     if ($joins) {
       return qsprintf($conn, '%LJ', $joins);
     } else {
       return qsprintf($conn, '');
     }
   }
 
   protected function buildWhereClause(AphrontDatabaseConnection $conn) {
     $where = array();
 
     if ($this->ids !== null) {
       $where[] = qsprintf(
         $conn,
         't.id IN (%Ld)',
         $this->ids);
     }
 
     if ($this->phids !== null) {
       $where[] = qsprintf(
         $conn,
         't.phid IN (%Ls)',
         $this->phids);
     }
 
     if ($this->versionMin !== null) {
       $where[] = qsprintf(
         $conn,
         't.triggerVersion >= %d',
         $this->versionMin);
     }
 
     if ($this->versionMax !== null) {
       $where[] = qsprintf(
         $conn,
         't.triggerVersion <= %d',
         $this->versionMax);
     }
 
     if ($this->nextEpochMin !== null) {
       $where[] = qsprintf(
         $conn,
         'e.nextEventEpoch >= %d',
         $this->nextEpochMin);
     }
 
     if ($this->nextEpochMax !== null) {
       $where[] = qsprintf(
         $conn,
         'e.nextEventEpoch <= %d',
         $this->nextEpochMax);
     }
 
     return $this->formatWhereClause($conn, $where);
   }
 
   private function buildOrderClause(AphrontDatabaseConnection $conn_r) {
     switch ($this->order) {
       case self::ORDER_ID:
         return qsprintf(
           $conn_r,
           'ORDER BY id DESC');
       case self::ORDER_EXECUTION:
         return qsprintf(
           $conn_r,
           'ORDER BY e.nextEventEpoch ASC, e.id ASC');
       case self::ORDER_VERSION:
         return qsprintf(
           $conn_r,
           'ORDER BY t.triggerVersion ASC');
       default:
         throw new Exception(
           pht(
             'Unsupported order "%s".',
             $this->order));
     }
   }
 
 }
diff --git a/src/infrastructure/daemon/workers/storage/PhabricatorWorkerTrigger.php b/src/infrastructure/daemon/workers/storage/PhabricatorWorkerTrigger.php
index 7b9a24d7e0..70a6c6d0f5 100644
--- a/src/infrastructure/daemon/workers/storage/PhabricatorWorkerTrigger.php
+++ b/src/infrastructure/daemon/workers/storage/PhabricatorWorkerTrigger.php
@@ -1,193 +1,194 @@
 <?php
 
 final class PhabricatorWorkerTrigger
   extends PhabricatorWorkerDAO
   implements
     PhabricatorDestructibleInterface,
     PhabricatorPolicyInterface {
 
   protected $triggerVersion;
   protected $clockClass;
   protected $clockProperties;
   protected $actionClass;
   protected $actionProperties;
 
   private $action = self::ATTACHABLE;
   private $clock = self::ATTACHABLE;
   private $event = self::ATTACHABLE;
 
   protected function getConfiguration() {
     return array(
       self::CONFIG_TIMESTAMPS => false,
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_SERIALIZATION => array(
         'clockProperties' => self::SERIALIZATION_JSON,
         'actionProperties' => self::SERIALIZATION_JSON,
       ),
       self::CONFIG_COLUMN_SCHEMA => array(
         'triggerVersion' => 'uint32',
         'clockClass' => 'text64',
         'actionClass' => 'text64',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_trigger' => array(
           'columns' => array('triggerVersion'),
           'unique' => true,
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function save() {
     $conn_w = $this->establishConnection('w');
 
     $this->openTransaction();
       $next_version = LiskDAO::loadNextCounterValue(
         $conn_w,
         PhabricatorTriggerDaemon::COUNTER_VERSION);
       $this->setTriggerVersion($next_version);
 
       $result = parent::save();
     $this->saveTransaction();
 
     return $this;
   }
 
   public function generatePHID() {
     return PhabricatorPHID::generateNewPHID(
       PhabricatorWorkerTriggerPHIDType::TYPECONST);
   }
 
   /**
    * Return the next time this trigger should execute.
    *
    * This method can be called either after the daemon executed the trigger
    * successfully (giving the trigger an opportunity to reschedule itself
    * into the future, if it is a recurring event) or after the trigger itself
    * is changed (usually because of an application edit). The `$is_reschedule`
    * parameter distinguishes between these cases.
    *
-   * @param int|null Epoch of the most recent successful event execution.
-   * @param bool `true` if we're trying to reschedule the event after
-   *   execution; `false` if this is in response to a trigger update.
+   * @param int|null $last_epoch Epoch of the most recent successful event
+   *   execution.
+   * @param bool $is_reschedule `true` if we're trying to reschedule the event
+   *   after execution; `false` if this is in response to a trigger update.
    * @return int|null Return an epoch to schedule the next event execution,
    *   or `null` to stop the event from executing again.
    */
   public function getNextEventEpoch($last_epoch, $is_reschedule) {
     return $this->getClock()->getNextEventEpoch($last_epoch, $is_reschedule);
   }
 
 
   /**
    * Execute the event.
    *
-   * @param int|null Epoch of previous execution, or null if this is the first
-   *   execution.
-   * @param int Scheduled epoch of this execution. This may not be the same
-   *   as the current time.
+   * @param int|null $last_event Epoch of previous execution, or null if this
+   *   is the first execution.
+   * @param int $this_event Scheduled epoch of this execution. This may not be
+   *   the same as the current time.
    * @return void
    */
   public function executeTrigger($last_event, $this_event) {
     return $this->getAction()->execute($last_event, $this_event);
   }
 
   public function getEvent() {
     return $this->assertAttached($this->event);
   }
 
   public function attachEvent(PhabricatorWorkerTriggerEvent $event = null) {
     $this->event = $event;
     return $this;
   }
 
   public function setAction(PhabricatorTriggerAction $action) {
     $this->actionClass = get_class($action);
     $this->actionProperties = $action->getProperties();
     return $this->attachAction($action);
   }
 
   public function getAction() {
     return $this->assertAttached($this->action);
   }
 
   public function attachAction(PhabricatorTriggerAction $action) {
     $this->action = $action;
     return $this;
   }
 
   public function setClock(PhabricatorTriggerClock $clock) {
     $this->clockClass = get_class($clock);
     $this->clockProperties = $clock->getProperties();
     return $this->attachClock($clock);
   }
 
   public function getClock() {
     return $this->assertAttached($this->clock);
   }
 
   public function attachClock(PhabricatorTriggerClock $clock) {
     $this->clock = $clock;
     return $this;
   }
 
 
   /**
    * Predict the epoch at which this trigger will next fire.
    *
    * @return int|null  Epoch when the event will next fire, or `null` if it is
    *   not planned to trigger.
    */
   public function getNextEventPrediction() {
     // NOTE: We're basically echoing the database state here, so this won't
     // necessarily be accurate if the caller just updated the object but has
     // not saved it yet. That's a weird use case and would require more
     // gymnastics, so don't bother trying to get it totally correct for now.
 
     if ($this->getEvent()) {
       return $this->getEvent()->getNextEventEpoch();
     } else {
       return $this->getNextEventEpoch(null, $is_reschedule = false);
     }
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
 
     $this->openTransaction();
       queryfx(
         $this->establishConnection('w'),
         'DELETE FROM %T WHERE triggerID = %d',
         id(new PhabricatorWorkerTriggerEvent())->getTableName(),
         $this->getID());
 
       $this->delete();
     $this->saveTransaction();
   }
 
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
 
   // NOTE: Triggers are low-level infrastructure and do not have real
   // policies, but implementing the policy interface allows us to use
   // infrastructure like handles.
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
     );
   }
 
   public function getPolicy($capability) {
     return PhabricatorPolicies::getMostOpenPolicy();
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return true;
   }
 
 }
diff --git a/src/infrastructure/diff/PhabricatorDifferenceEngine.php b/src/infrastructure/diff/PhabricatorDifferenceEngine.php
index 35aa63a1b8..3326a939d1 100644
--- a/src/infrastructure/diff/PhabricatorDifferenceEngine.php
+++ b/src/infrastructure/diff/PhabricatorDifferenceEngine.php
@@ -1,261 +1,261 @@
 <?php
 
 /**
  * Utility class which encapsulates some shared behavior between different
  * applications which render diffs.
  *
  * @task config Configuring the Engine
  * @task diff Generating Diffs
  */
 final class PhabricatorDifferenceEngine extends Phobject {
 
 
   private $oldName;
   private $newName;
   private $normalize;
 
 
 /* -(  Configuring the Engine  )--------------------------------------------- */
 
 
   /**
    * Set the name to identify the old file with. Primarily cosmetic.
    *
-   * @param  string Old file name.
+   * @param  string $old_name Old file name.
    * @return this
    * @task config
    */
   public function setOldName($old_name) {
     $this->oldName = $old_name;
     return $this;
   }
 
 
   /**
    * Set the name to identify the new file with. Primarily cosmetic.
    *
-   * @param  string New file name.
+   * @param  string $new_name New file name.
    * @return this
    * @task config
    */
   public function setNewName($new_name) {
     $this->newName = $new_name;
     return $this;
   }
 
 
   public function setNormalize($normalize) {
     $this->normalize = $normalize;
     return $this;
   }
 
   public function getNormalize() {
     return $this->normalize;
   }
 
 
 /* -(  Generating Diffs  )--------------------------------------------------- */
 
 
   /**
    * Generate a raw diff from two raw files. This is a lower-level API than
    * @{method:generateChangesetFromFileContent}, but may be useful if you need
    * to use a custom parser configuration, as with Diffusion.
    *
-   * @param string Entire previous file content.
-   * @param string Entire current file content.
+   * @param string $old Entire previous file content.
+   * @param string $new Entire current file content.
    * @return string Raw diff between the two files.
    * @task diff
    */
   public function generateRawDiffFromFileContent($old, $new) {
 
     $options = array();
 
     // Generate diffs with full context.
     $options[] = '-U65535';
 
     $old_name = nonempty($this->oldName, '/dev/universe').' 9999-99-99';
     $new_name = nonempty($this->newName, '/dev/universe').' 9999-99-99';
 
     $options[] = '-L';
     $options[] = $old_name;
     $options[] = '-L';
     $options[] = $new_name;
 
     $normalize = $this->getNormalize();
     if ($normalize) {
       $old = $this->normalizeFile($old);
       $new = $this->normalizeFile($new);
     }
 
     $old_tmp = new TempFile();
     $new_tmp = new TempFile();
 
     Filesystem::writeFile($old_tmp, $old);
     Filesystem::writeFile($new_tmp, $new);
     list($err, $diff) = exec_manual(
       'diff %Ls %s %s',
       $options,
       $old_tmp,
       $new_tmp);
 
     if (!$err) {
       // This indicates that the two files are the same. Build a synthetic,
       // changeless diff so that we can still render the raw, unchanged file
       // instead of being forced to just say "this file didn't change" since we
       // don't have the content.
 
       $entire_file = explode("\n", $old);
       foreach ($entire_file as $k => $line) {
         $entire_file[$k] = ' '.$line;
       }
 
       $len = count($entire_file);
       $entire_file = implode("\n", $entire_file);
 
       // TODO: If both files were identical but missing newlines, we probably
       // get this wrong. Unclear if it ever matters.
 
       // This is a bit hacky but the diff parser can handle it.
       $diff = "--- {$old_name}\n".
               "+++ {$new_name}\n".
               "@@ -1,{$len} +1,{$len} @@\n".
               $entire_file."\n";
     }
 
     return $diff;
   }
 
 
   /**
    * Generate an @{class:DifferentialChangeset} from two raw files. This is
    * principally useful because you can feed the output to
    * @{class:DifferentialChangesetParser} in order to render it.
    *
-   * @param string Entire previous file content.
-   * @param string Entire current file content.
+   * @param string $old Entire previous file content.
+   * @param string $new Entire current file content.
    * @return @{class:DifferentialChangeset} Synthetic changeset.
    * @task diff
    */
   public function generateChangesetFromFileContent($old, $new) {
     $diff = $this->generateRawDiffFromFileContent($old, $new);
 
     $changes = id(new ArcanistDiffParser())->parseDiff($diff);
     $diff = DifferentialDiff::newEphemeralFromRawChanges(
       $changes);
     return head($diff->getChangesets());
   }
 
   private function normalizeFile($corpus) {
     // We can freely apply any other transformations we want to here: we have
     // no constraints on what we need to preserve. If we normalize every line
     // to "cat", the diff will still work, the alignment of the "-" / "+"
     // lines will just be very hard to read.
 
     // In general, we'll make the diff better if we normalize two lines that
     // humans think are the same.
 
     // We'll make the diff worse if we normalize two lines that humans think
     // are different.
 
 
     // Strip all whitespace present anywhere in the diff, since humans never
     // consider whitespace changes to alter the line into a "different line"
     // even when they're semantic (e.g., in a string constant). This covers
     // indentation changes, trailing whitepspace, and formatting changes
     // like "+/-".
     $corpus = preg_replace('/[ \t]/', '', $corpus);
 
     return $corpus;
   }
 
   public static function applyIntralineDiff($str, $intra_stack) {
     $buf = '';
     $p = $s = $e = 0; // position, start, end
     $highlight = $tag = $ent = false;
     $highlight_o = '<span class="bright">';
     $highlight_c = '</span>';
 
     $depth_in = '<span class="depth-in">';
     $depth_out = '<span class="depth-out">';
 
     $is_html = false;
     if ($str instanceof PhutilSafeHTML) {
       $is_html = true;
       $str = $str->getHTMLContent();
     }
 
     $n = strlen($str);
     for ($i = 0; $i < $n; $i++) {
 
       if ($p == $e) {
         do {
           if (empty($intra_stack)) {
             $buf .= substr($str, $i);
             break 2;
           }
           $stack = array_shift($intra_stack);
           $s = $e;
           $e += $stack[1];
         } while ($stack[0] === 0);
 
         switch ($stack[0]) {
           case '>':
             $open_tag = $depth_in;
             break;
           case '<':
             $open_tag = $depth_out;
             break;
           default:
             $open_tag = $highlight_o;
             break;
         }
       }
 
       if (!$highlight && !$tag && !$ent && $p == $s) {
         $buf .= $open_tag;
         $highlight = true;
       }
 
       if ($str[$i] == '<') {
         $tag = true;
         if ($highlight) {
           $buf .= $highlight_c;
         }
       }
 
       if (!$tag) {
         if ($str[$i] == '&') {
           $ent = true;
         }
         if ($ent && $str[$i] == ';') {
           $ent = false;
         }
         if (!$ent) {
           $p++;
         }
       }
 
       $buf .= $str[$i];
 
       if ($tag && $str[$i] == '>') {
         $tag = false;
         if ($highlight) {
           $buf .= $open_tag;
         }
       }
 
       if ($highlight && ($p == $e || $i == $n - 1)) {
         $buf .= $highlight_c;
         $highlight = false;
       }
     }
 
     if ($is_html) {
       return phutil_safe_html($buf);
     }
 
     return $buf;
   }
 
 }
diff --git a/src/infrastructure/edges/editor/PhabricatorEdgeEditor.php b/src/infrastructure/edges/editor/PhabricatorEdgeEditor.php
index d019c7d0fe..8ab8ddce58 100644
--- a/src/infrastructure/edges/editor/PhabricatorEdgeEditor.php
+++ b/src/infrastructure/edges/editor/PhabricatorEdgeEditor.php
@@ -1,405 +1,405 @@
 <?php
 
 /**
  * Add and remove edges between objects. You can use
  * @{class:PhabricatorEdgeQuery} to load object edges. For more information
  * on edges, see @{article:Using Edges}.
  *
  * Edges are not directly policy aware, and this editor makes low-level changes
  * below the policy layer.
  *
  *    name=Adding Edges
  *    $src  = $earth_phid;
  *    $type = PhabricatorEdgeConfig::TYPE_BODY_HAS_SATELLITE;
  *    $dst  = $moon_phid;
  *
  *    id(new PhabricatorEdgeEditor())
  *      ->addEdge($src, $type, $dst)
  *      ->save();
  *
  * @task edit     Editing Edges
  * @task cycles   Cycle Prevention
  * @task internal Internals
  */
 final class PhabricatorEdgeEditor extends Phobject {
 
   private $addEdges = array();
   private $remEdges = array();
   private $openTransactions = array();
 
 
 /* -(  Editing Edges  )------------------------------------------------------ */
 
 
   /**
    * Add a new edge (possibly also adding its inverse). Changes take effect when
    * you call @{method:save}. If the edge already exists, it will not be
    * overwritten, but if data is attached to the edge it will be updated.
    * Removals queued with @{method:removeEdge} are executed before
    * adds, so the effect of removing and adding the same edge is to overwrite
    * any existing edge.
    *
    * The `$options` parameter accepts these values:
    *
    *   - `data` Optional, data to write onto the edge.
    *   - `inverse_data` Optional, data to write on the inverse edge. If not
    *     provided, `data` will be written.
    *
-   * @param phid  Source object PHID.
-   * @param const Edge type constant.
-   * @param phid  Destination object PHID.
-   * @param map   Options map (see documentation).
+   * @param phid  $src Source object PHID.
+   * @param const $type Edge type constant.
+   * @param phid  $dst Destination object PHID.
+   * @param map?  $options Options map (see documentation).
    * @return this
    *
    * @task edit
    */
   public function addEdge($src, $type, $dst, array $options = array()) {
     foreach ($this->buildEdgeSpecs($src, $type, $dst, $options) as $spec) {
       $this->addEdges[] = $spec;
     }
     return $this;
   }
 
 
   /**
    * Remove an edge (possibly also removing its inverse). Changes take effect
    * when you call @{method:save}. If an edge does not exist, the removal
    * will be ignored. Edges are added after edges are removed, so the effect of
    * a remove plus an add is to overwrite.
    *
-   * @param phid  Source object PHID.
-   * @param const Edge type constant.
-   * @param phid  Destination object PHID.
+   * @param phid  $src Source object PHID.
+   * @param const $type Edge type constant.
+   * @param phid  $dst Destination object PHID.
    * @return this
    *
    * @task edit
    */
   public function removeEdge($src, $type, $dst) {
     foreach ($this->buildEdgeSpecs($src, $type, $dst) as $spec) {
       $this->remEdges[] = $spec;
     }
     return $this;
   }
 
 
   /**
    * Apply edge additions and removals queued by @{method:addEdge} and
    * @{method:removeEdge}. Note that transactions are opened, all additions and
    * removals are executed, and then transactions are saved. Thus, in some cases
    * it may be slightly more efficient to perform multiple edit operations
    * (e.g., adds followed by removals) if their outcomes are not dependent,
    * since transactions will not be held open as long.
    *
    * @task edit
    */
   public function save() {
 
     $cycle_types = $this->getPreventCyclesEdgeTypes();
 
     $locks = array();
     $caught = null;
     try {
 
       // NOTE: We write edge data first, before doing any transactions, since
       // it's OK if we just leave it hanging out in space unattached to
       // anything.
       $this->writeEdgeData();
 
       // If we're going to perform cycle detection, lock the edge type before
       // doing edits.
       if ($cycle_types) {
         $src_phids = ipull($this->addEdges, 'src');
         foreach ($cycle_types as $cycle_type) {
           $key = 'edge.cycle:'.$cycle_type;
           $locks[] = PhabricatorGlobalLock::newLock($key)->lock(15);
         }
       }
 
       static $id = 0;
       $id++;
 
       // NOTE: Removes first, then adds, so that "remove + add" is a useful
       // operation meaning "overwrite".
 
       $this->executeRemoves();
       $this->executeAdds();
 
       foreach ($cycle_types as $cycle_type) {
         $this->detectCycles($src_phids, $cycle_type);
       }
 
       $this->saveTransactions();
     } catch (Exception $ex) {
       $caught = $ex;
     }
 
     if ($caught) {
       $this->killTransactions();
     }
 
     foreach ($locks as $lock) {
       $lock->unlock();
     }
 
     if ($caught) {
       throw $caught;
     }
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * Build the specification for an edge operation, and possibly build its
    * inverse as well.
    *
    * @task internal
    */
   private function buildEdgeSpecs($src, $type, $dst, array $options = array()) {
     $data = array();
     if (!empty($options['data'])) {
       $data['data'] = $options['data'];
     }
 
     $src_type = phid_get_type($src);
     $dst_type = phid_get_type($dst);
 
     $specs = array();
     $specs[] = array(
       'src'       => $src,
       'src_type'  => $src_type,
       'dst'       => $dst,
       'dst_type'  => $dst_type,
       'type'      => $type,
       'data'      => $data,
     );
 
     $type_obj = PhabricatorEdgeType::getByConstant($type);
     $inverse = $type_obj->getInverseEdgeConstant();
     if ($inverse !== null) {
 
       // If `inverse_data` is set, overwrite the edge data. Normally, just
       // write the same data to the inverse edge.
       if (array_key_exists('inverse_data', $options)) {
         $data['data'] = $options['inverse_data'];
       }
 
       $specs[] = array(
         'src'       => $dst,
         'src_type'  => $dst_type,
         'dst'       => $src,
         'dst_type'  => $src_type,
         'type'      => $inverse,
         'data'      => $data,
       );
     }
 
     return $specs;
   }
 
 
   /**
    * Write edge data.
    *
    * @task internal
    */
   private function writeEdgeData() {
     $adds = $this->addEdges;
 
     $writes = array();
     foreach ($adds as $key => $edge) {
       if ($edge['data']) {
         $writes[] = array($key, $edge['src_type'], json_encode($edge['data']));
       }
     }
 
     foreach ($writes as $write) {
       list($key, $src_type, $data) = $write;
       $conn_w = PhabricatorEdgeConfig::establishConnection($src_type, 'w');
       queryfx(
         $conn_w,
         'INSERT INTO %T (data) VALUES (%s)',
         PhabricatorEdgeConfig::TABLE_NAME_EDGEDATA,
         $data);
       $this->addEdges[$key]['data_id'] = $conn_w->getInsertID();
     }
   }
 
 
   /**
    * Add queued edges.
    *
    * @task internal
    */
   private function executeAdds() {
     $adds = $this->addEdges;
     $adds = igroup($adds, 'src_type');
 
     // Assign stable sequence numbers to each edge, so we have a consistent
     // ordering across edges by source and type.
     foreach ($adds as $src_type => $edges) {
       $edges_by_src = igroup($edges, 'src');
       foreach ($edges_by_src as $src => $src_edges) {
         $seq = 0;
         foreach ($src_edges as $key => $edge) {
           $src_edges[$key]['seq'] = $seq++;
           $src_edges[$key]['dateCreated'] = time();
         }
         $edges_by_src[$src] = $src_edges;
       }
       $adds[$src_type] = array_mergev($edges_by_src);
     }
 
     $inserts = array();
     foreach ($adds as $src_type => $edges) {
       $conn_w = PhabricatorEdgeConfig::establishConnection($src_type, 'w');
       $sql = array();
       foreach ($edges as $edge) {
         $sql[] = qsprintf(
           $conn_w,
           '(%s, %d, %s, %d, %d, %nd)',
           $edge['src'],
           $edge['type'],
           $edge['dst'],
           $edge['dateCreated'],
           $edge['seq'],
           idx($edge, 'data_id'));
       }
       $inserts[] = array($conn_w, $sql);
     }
 
     foreach ($inserts as $insert) {
       list($conn_w, $sql) = $insert;
       $conn_w->openTransaction();
       $this->openTransactions[] = $conn_w;
 
       foreach (PhabricatorLiskDAO::chunkSQL($sql) as $chunk) {
         queryfx(
           $conn_w,
           'INSERT INTO %T (src, type, dst, dateCreated, seq, dataID)
             VALUES %LQ ON DUPLICATE KEY UPDATE dataID = VALUES(dataID)',
           PhabricatorEdgeConfig::TABLE_NAME_EDGE,
           $chunk);
       }
     }
   }
 
 
   /**
    * Remove queued edges.
    *
    * @task internal
    */
   private function executeRemoves() {
     $rems = $this->remEdges;
     $rems = igroup($rems, 'src_type');
 
     $deletes = array();
     foreach ($rems as $src_type => $edges) {
       $conn_w = PhabricatorEdgeConfig::establishConnection($src_type, 'w');
       $sql = array();
       foreach ($edges as $edge) {
         $sql[] = qsprintf(
           $conn_w,
           '(src = %s AND type = %d AND dst = %s)',
           $edge['src'],
           $edge['type'],
           $edge['dst']);
       }
       $deletes[] = array($conn_w, $sql);
     }
 
     foreach ($deletes as $delete) {
       list($conn_w, $sql) = $delete;
 
       $conn_w->openTransaction();
       $this->openTransactions[] = $conn_w;
 
       foreach (array_chunk($sql, 256) as $chunk) {
         queryfx(
           $conn_w,
           'DELETE FROM %T WHERE %LO',
           PhabricatorEdgeConfig::TABLE_NAME_EDGE,
           $chunk);
       }
     }
   }
 
 
   /**
    * Save open transactions.
    *
    * @task internal
    */
   private function saveTransactions() {
     foreach ($this->openTransactions as $key => $conn_w) {
       $conn_w->saveTransaction();
       unset($this->openTransactions[$key]);
     }
   }
 
   private function killTransactions() {
     foreach ($this->openTransactions as $key => $conn_w) {
       $conn_w->killTransaction();
       unset($this->openTransactions[$key]);
     }
   }
 
 
 /* -(  Cycle Prevention  )--------------------------------------------------- */
 
 
   /**
    * Get a list of all edge types which are being added, and which we should
    * prevent cycles on.
    *
    * @return list<const> List of edge types which should have cycles prevented.
    * @task cycle
    */
   private function getPreventCyclesEdgeTypes() {
     $edge_types = array();
     foreach ($this->addEdges as $edge) {
       $edge_types[$edge['type']] = true;
     }
     foreach ($edge_types as $type => $ignored) {
       $type_obj = PhabricatorEdgeType::getByConstant($type);
       if (!$type_obj->shouldPreventCycles()) {
         unset($edge_types[$type]);
       }
     }
     return array_keys($edge_types);
   }
 
 
   /**
    * Detect graph cycles of a given edge type. If the edit introduces a cycle,
    * a @{class:PhabricatorEdgeCycleException} is thrown with details.
    *
    * @return void
    * @task cycle
    */
   private function detectCycles(array $phids, $edge_type) {
     // For simplicity, we just seed the graph with the affected nodes rather
     // than seeding it with their edges. To do this, we just add synthetic
     // edges from an imaginary '<seed>' node to the known edges.
 
 
     $graph = id(new PhabricatorEdgeGraph())
       ->setEdgeType($edge_type)
       ->addNodes(
         array(
           '<seed>' => $phids,
         ))
       ->loadGraph();
 
     foreach ($phids as $phid) {
       $cycle = $graph->detectCycles($phid);
       if ($cycle) {
         throw new PhabricatorEdgeCycleException($edge_type, $cycle);
       }
     }
   }
 
 
 }
diff --git a/src/infrastructure/edges/query/PhabricatorEdgeQuery.php b/src/infrastructure/edges/query/PhabricatorEdgeQuery.php
index f63e827431..eeb6123abf 100644
--- a/src/infrastructure/edges/query/PhabricatorEdgeQuery.php
+++ b/src/infrastructure/edges/query/PhabricatorEdgeQuery.php
@@ -1,339 +1,339 @@
 <?php
 
 /**
  * Load object edges created by @{class:PhabricatorEdgeEditor}.
  *
  *   name=Querying Edges
  *   $src  = $earth_phid;
  *   $type = PhabricatorEdgeConfig::TYPE_BODY_HAS_SATELLITE;
  *
  *   // Load the earth's satellites.
  *   $satellite_edges = id(new PhabricatorEdgeQuery())
  *     ->withSourcePHIDs(array($src))
  *     ->withEdgeTypes(array($type))
  *     ->execute();
  *
  * For more information on edges, see @{article:Using Edges}.
  *
  * @task config   Configuring the Query
  * @task exec     Executing the Query
  * @task internal Internal
  */
 final class PhabricatorEdgeQuery extends PhabricatorQuery {
 
   private $sourcePHIDs;
   private $destPHIDs;
   private $edgeTypes;
   private $resultSet;
 
   const ORDER_OLDEST_FIRST = 'order:oldest';
   const ORDER_NEWEST_FIRST = 'order:newest';
   private $order = self::ORDER_NEWEST_FIRST;
 
   private $needEdgeData;
 
 
 /* -(  Configuring the Query  )---------------------------------------------- */
 
 
   /**
    * Find edges originating at one or more source PHIDs. You MUST provide this
    * to execute an edge query.
    *
-   * @param list List of source PHIDs.
+   * @param list $source_phids List of source PHIDs.
    * @return this
    *
    * @task config
    */
   public function withSourcePHIDs(array $source_phids) {
     if (!$source_phids) {
       throw new Exception(
         pht(
           'Edge list passed to "withSourcePHIDs(...)" is empty, but it must '.
           'be nonempty.'));
     }
 
     $this->sourcePHIDs = $source_phids;
     return $this;
   }
 
 
   /**
    * Find edges terminating at one or more destination PHIDs.
    *
-   * @param list List of destination PHIDs.
+   * @param list $dest_phids List of destination PHIDs.
    * @return this
    *
    */
   public function withDestinationPHIDs(array $dest_phids) {
     $this->destPHIDs = $dest_phids;
     return $this;
   }
 
 
   /**
    * Find edges of specific types.
    *
-   * @param list List of PhabricatorEdgeConfig type constants.
+   * @param list $types List of PhabricatorEdgeConfig type constants.
    * @return this
    *
    * @task config
    */
   public function withEdgeTypes(array $types) {
     $this->edgeTypes = $types;
     return $this;
   }
 
 
   /**
    * Configure the order edge results are returned in.
    *
-   * @param const Order constant.
+   * @param const $order Order constant.
    * @return this
    *
    * @task config
    */
   public function setOrder($order) {
     $this->order = $order;
     return $this;
   }
 
 
   /**
    * When loading edges, also load edge data.
    *
-   * @param bool True to load edge data.
+   * @param bool $need True to load edge data.
    * @return this
    *
    * @task config
    */
   public function needEdgeData($need) {
     $this->needEdgeData = $need;
     return $this;
   }
 
 
 /* -(  Executing the Query  )------------------------------------------------ */
 
 
   /**
    * Convenience method for loading destination PHIDs with one source and one
    * edge type. Equivalent to building a full query, but simplifies a common
    * use case.
    *
-   * @param phid  Source PHID.
-   * @param const Edge type.
+   * @param phid  $src_phid Source PHID.
+   * @param const $edge_type Edge type.
    * @return list<phid> List of destination PHIDs.
    */
   public static function loadDestinationPHIDs($src_phid, $edge_type) {
     $edges = id(new PhabricatorEdgeQuery())
       ->withSourcePHIDs(array($src_phid))
       ->withEdgeTypes(array($edge_type))
       ->execute();
     return array_keys($edges[$src_phid][$edge_type]);
   }
 
   /**
    * Convenience method for loading a single edge's metadata for
    * a given source, destination, and edge type. Returns null
    * if the edge does not exist or does not have metadata. Builds
    * and immediately executes a full query.
    *
-   * @param phid  Source PHID.
-   * @param const Edge type.
-   * @param phid  Destination PHID.
+   * @param phid  $src_phid Source PHID.
+   * @param const $edge_type Edge type.
+   * @param phid  $dest_phid Destination PHID.
    * @return wild Edge annotation (or null).
    */
   public static function loadSingleEdgeData($src_phid, $edge_type, $dest_phid) {
     $edges = id(new PhabricatorEdgeQuery())
       ->withSourcePHIDs(array($src_phid))
       ->withEdgeTypes(array($edge_type))
       ->withDestinationPHIDs(array($dest_phid))
       ->needEdgeData(true)
       ->execute();
 
     if (isset($edges[$src_phid][$edge_type][$dest_phid]['data'])) {
       return $edges[$src_phid][$edge_type][$dest_phid]['data'];
     }
     return null;
   }
 
 
   /**
    * Load specified edges.
    *
    * @task exec
    */
   public function execute() {
     if ($this->sourcePHIDs === null) {
       throw new Exception(
         pht(
           'You must use "withSourcePHIDs()" to query edges.'));
     }
 
     $sources = phid_group_by_type($this->sourcePHIDs);
 
     $result = array();
 
     // When a query specifies types, make sure we return data for all queried
     // types.
     if ($this->edgeTypes) {
       foreach ($this->sourcePHIDs as $phid) {
         foreach ($this->edgeTypes as $type) {
           $result[$phid][$type] = array();
         }
       }
     }
 
     foreach ($sources as $type => $phids) {
       $conn_r = PhabricatorEdgeConfig::establishConnection($type, 'r');
 
       $where = $this->buildWhereClause($conn_r);
       $order = $this->buildOrderClause($conn_r);
 
       $edges = queryfx_all(
         $conn_r,
         'SELECT edge.* FROM %T edge %Q %Q',
         PhabricatorEdgeConfig::TABLE_NAME_EDGE,
         $where,
         $order);
 
       if ($this->needEdgeData) {
         $data_ids = array_filter(ipull($edges, 'dataID'));
         $data_map = array();
         if ($data_ids) {
           $data_rows = queryfx_all(
             $conn_r,
             'SELECT edgedata.* FROM %T edgedata WHERE id IN (%Ld)',
             PhabricatorEdgeConfig::TABLE_NAME_EDGEDATA,
             $data_ids);
           foreach ($data_rows as $row) {
             $data_map[$row['id']] = idx(
               phutil_json_decode($row['data']),
               'data');
           }
         }
         foreach ($edges as $key => $edge) {
           $edges[$key]['data'] = idx($data_map, $edge['dataID'], array());
         }
       }
 
       foreach ($edges as $edge) {
         $result[$edge['src']][$edge['type']][$edge['dst']] = $edge;
       }
     }
 
     $this->resultSet = $result;
     return $result;
   }
 
 
   /**
    * Convenience function for selecting edge destination PHIDs after calling
    * execute().
    *
    * Returns a flat list of PHIDs matching the provided source PHID and type
    * filters. By default, the filters are empty so all PHIDs will be returned.
    * For example, if you're doing a batch query from several sources, you might
    * write code like this:
    *
    *   $query = new PhabricatorEdgeQuery();
    *   $query->setViewer($viewer);
    *   $query->withSourcePHIDs(mpull($objects, 'getPHID'));
    *   $query->withEdgeTypes(array($some_type));
    *   $query->execute();
    *
    *   // Gets all of the destinations.
    *   $all_phids = $query->getDestinationPHIDs();
    *   $handles = id(new PhabricatorHandleQuery())
    *     ->setViewer($viewer)
    *     ->withPHIDs($all_phids)
    *     ->execute();
    *
    *   foreach ($objects as $object) {
    *     // Get all of the destinations for the given object.
    *     $dst_phids = $query->getDestinationPHIDs(array($object->getPHID()));
    *     $object->attachHandles(array_select_keys($handles, $dst_phids));
    *   }
    *
-   * @param list? List of PHIDs to select, or empty to select all.
-   * @param list? List of edge types to select, or empty to select all.
+   * @param list? $src_phids List of PHIDs to select, or empty to select all.
+   * @param list? $types List of edge types to select, or empty to select all.
    * @return list<phid> List of matching destination PHIDs.
    */
   public function getDestinationPHIDs(
     array $src_phids = array(),
     array $types = array()) {
     if ($this->resultSet === null) {
       throw new PhutilInvalidStateException('execute');
     }
 
     $result_phids = array();
 
     $set = $this->resultSet;
     if ($src_phids) {
       $set = array_select_keys($set, $src_phids);
     }
 
     foreach ($set as $src => $edges_by_type) {
       if ($types) {
         $edges_by_type = array_select_keys($edges_by_type, $types);
       }
 
       foreach ($edges_by_type as $edges) {
         foreach ($edges as $edge_phid => $edge) {
           $result_phids[$edge_phid] = true;
         }
       }
     }
 
     return array_keys($result_phids);
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * @task internal
    */
   protected function buildWhereClause(AphrontDatabaseConnection $conn) {
     $where = array();
 
     if ($this->sourcePHIDs) {
       $where[] = qsprintf(
         $conn,
         'edge.src IN (%Ls)',
         $this->sourcePHIDs);
     }
 
     if ($this->edgeTypes) {
       $where[] = qsprintf(
         $conn,
         'edge.type IN (%Ls)',
         $this->edgeTypes);
     }
 
     if ($this->destPHIDs) {
       // potentially complain if $this->edgeType was not set
       $where[] = qsprintf(
         $conn,
         'edge.dst IN (%Ls)',
         $this->destPHIDs);
     }
 
     return $this->formatWhereClause($conn, $where);
   }
 
 
   /**
    * @task internal
    */
   private function buildOrderClause(AphrontDatabaseConnection $conn) {
     if ($this->order == self::ORDER_NEWEST_FIRST) {
       return qsprintf($conn, 'ORDER BY edge.dateCreated DESC, edge.seq DESC');
     } else {
       return qsprintf($conn, 'ORDER BY edge.dateCreated ASC, edge.seq ASC');
     }
   }
 
 }
diff --git a/src/infrastructure/env/PhabricatorEnv.php b/src/infrastructure/env/PhabricatorEnv.php
index a471874e43..3311869f23 100644
--- a/src/infrastructure/env/PhabricatorEnv.php
+++ b/src/infrastructure/env/PhabricatorEnv.php
@@ -1,990 +1,990 @@
 <?php
 
 /**
  * Manages the execution environment configuration, exposing APIs to read
  * configuration settings and other similar values that are derived directly
  * from configuration settings.
  *
  *
  * = Reading Configuration =
  *
  * The primary role of this class is to provide an API for reading
  * Phabricator configuration, @{method:getEnvConfig}:
  *
  *   $value = PhabricatorEnv::getEnvConfig('some.key', $default);
  *
  * The class also handles some URI construction based on configuration, via
  * the methods @{method:getURI}, @{method:getProductionURI},
  * @{method:getCDNURI}, and @{method:getDoclink}.
  *
  * For configuration which allows you to choose a class to be responsible for
  * some functionality (e.g., which mail adapter to use to deliver email),
  * @{method:newObjectFromConfig} provides a simple interface that validates
  * the configured value.
  *
  *
  * = Unit Test Support =
  *
  * In unit tests, you can use @{method:beginScopedEnv} to create a temporary,
  * mutable environment. The method returns a scope guard object which restores
  * the environment when it is destroyed. For example:
  *
  *   public function testExample() {
  *     $env = PhabricatorEnv::beginScopedEnv();
  *     $env->overrideEnv('some.key', 'new-value-for-this-test');
  *
  *     // Some test which depends on the value of 'some.key'.
  *
  *   }
  *
  * Your changes will persist until the `$env` object leaves scope or is
  * destroyed.
  *
  * You should //not// use this in normal code.
  *
  *
  * @task read     Reading Configuration
  * @task uri      URI Validation
  * @task test     Unit Test Support
  * @task internal Internals
  */
 final class PhabricatorEnv extends Phobject {
 
   private static $sourceStack;
   private static $repairSource;
   private static $overrideSource;
   private static $requestBaseURI;
   private static $cache;
   private static $localeCode;
   private static $readOnly;
   private static $readOnlyReason;
 
   const READONLY_CONFIG = 'config';
   const READONLY_UNREACHABLE = 'unreachable';
   const READONLY_SEVERED = 'severed';
   const READONLY_MASTERLESS = 'masterless';
 
   /**
    * @phutil-external-symbol class PhabricatorStartup
    */
   public static function initializeWebEnvironment() {
     self::initializeCommonEnvironment(false);
   }
 
   public static function initializeScriptEnvironment($config_optional) {
     self::initializeCommonEnvironment($config_optional);
 
     // NOTE: This is dangerous in general, but we know we're in a script context
     // and are not vulnerable to CSRF.
     AphrontWriteGuard::allowDangerousUnguardedWrites(true);
 
     // There are several places where we log information (about errors, events,
     // service calls, etc.) for analysis via DarkConsole or similar. These are
     // useful for web requests, but grow unboundedly in long-running scripts and
     // daemons. Discard data as it arrives in these cases.
     PhutilServiceProfiler::getInstance()->enableDiscardMode();
     DarkConsoleErrorLogPluginAPI::enableDiscardMode();
     DarkConsoleEventPluginAPI::enableDiscardMode();
   }
 
 
   private static function initializeCommonEnvironment($config_optional) {
     PhutilErrorHandler::initialize();
 
     self::resetUmask();
     self::buildConfigurationSourceStack($config_optional);
 
     // Force a valid timezone. If both PHP and Phabricator configuration are
     // invalid, use UTC.
     $tz = self::getEnvConfig('phabricator.timezone');
     if ($tz) {
       @date_default_timezone_set($tz);
     }
     $ok = @date_default_timezone_set(date_default_timezone_get());
     if (!$ok) {
       date_default_timezone_set('UTC');
     }
 
     // Prepend '/support/bin' and append any paths to $PATH if we need to.
     $env_path = getenv('PATH');
     $phabricator_path = dirname(phutil_get_library_root('phabricator'));
     $support_path = $phabricator_path.'/support/bin';
     $env_path = $support_path.PATH_SEPARATOR.$env_path;
     $append_dirs = self::getEnvConfig('environment.append-paths');
     if (!empty($append_dirs)) {
       $append_path = implode(PATH_SEPARATOR, $append_dirs);
       $env_path = $env_path.PATH_SEPARATOR.$append_path;
     }
     putenv('PATH='.$env_path);
 
     // Write this back into $_ENV, too, so ExecFuture picks it up when creating
     // subprocess environments.
     $_ENV['PATH'] = $env_path;
 
 
     // If an instance identifier is defined, write it into the environment so
     // it's available to subprocesses.
     $instance = self::getEnvConfig('cluster.instance');
     if (phutil_nonempty_string($instance)) {
       putenv('PHABRICATOR_INSTANCE='.$instance);
       $_ENV['PHABRICATOR_INSTANCE'] = $instance;
     }
 
     PhabricatorEventEngine::initialize();
 
     // TODO: Add a "locale.default" config option once we have some reasonable
     // defaults which aren't silly nonsense.
     self::setLocaleCode('en_US');
 
     // Load the preamble utility library if we haven't already. On web
     // requests this loaded earlier, but we want to load it for non-web
     // requests so that unit tests can call these functions.
     require_once $phabricator_path.'/support/startup/preamble-utils.php';
   }
 
   public static function beginScopedLocale($locale_code) {
     return new PhabricatorLocaleScopeGuard($locale_code);
   }
 
   public static function getLocaleCode() {
     return self::$localeCode;
   }
 
   public static function setLocaleCode($locale_code) {
     if (!$locale_code) {
       return;
     }
 
     if ($locale_code == self::$localeCode) {
       return;
     }
 
     try {
       $locale = PhutilLocale::loadLocale($locale_code);
       $translations = PhutilTranslation::getTranslationMapForLocale(
         $locale_code);
 
       $override = self::getEnvConfig('translation.override');
       if (!is_array($override)) {
         $override = array();
       }
 
       PhutilTranslator::getInstance()
         ->setLocale($locale)
         ->setTranslations($override + $translations);
 
       self::$localeCode = $locale_code;
     } catch (Exception $ex) {
       // Just ignore this; the user likely has an out-of-date locale code.
     }
   }
 
   private static function buildConfigurationSourceStack($config_optional) {
     self::dropConfigCache();
 
     $stack = new PhabricatorConfigStackSource();
     self::$sourceStack = $stack;
 
     $default_source = id(new PhabricatorConfigDefaultSource())
       ->setName(pht('Global Default'));
     $stack->pushSource($default_source);
 
     $env = self::getSelectedEnvironmentName();
     if ($env) {
       $stack->pushSource(
         id(new PhabricatorConfigFileSource($env))
           ->setName(pht("File '%s'", $env)));
     }
 
     $stack->pushSource(
       id(new PhabricatorConfigLocalSource())
         ->setName(pht('Local Config')));
 
     // If the install overrides the database adapter, we might need to load
     // the database adapter class before we can push on the database config.
     // This config is locked and can't be edited from the web UI anyway.
     foreach (self::getEnvConfig('load-libraries') as $library) {
       phutil_load_library($library);
     }
 
     // Drop any class map caches, since they will have generated without
     // any classes from libraries. Without this, preflight setup checks can
     // cause generation of a setup check cache that omits checks defined in
     // libraries, for example.
     PhutilClassMapQuery::deleteCaches();
 
     // If custom libraries specify config options, they won't get default
     // values as the Default source has already been loaded, so we get it to
     // pull in all options from non-phabricator libraries now they are loaded.
     $default_source->loadExternalOptions();
 
     // If this install has site config sources, load them now.
     $site_sources = id(new PhutilClassMapQuery())
       ->setAncestorClass('PhabricatorConfigSiteSource')
       ->setSortMethod('getPriority')
       ->execute();
 
     foreach ($site_sources as $site_source) {
       $stack->pushSource($site_source);
 
       // If the site source did anything which reads config, throw it away
       // to make sure any additional site sources get clean reads.
       self::dropConfigCache();
     }
 
     $masters = PhabricatorDatabaseRef::getMasterDatabaseRefs();
     if (!$masters) {
       self::setReadOnly(true, self::READONLY_MASTERLESS);
     } else {
       // If any master is severed, we drop to readonly mode. In theory we
       // could try to continue if we're only missing some applications, but
       // this is very complex and we're unlikely to get it right.
 
       foreach ($masters as $master) {
         // Give severed masters one last chance to get healthy.
         if ($master->isSevered()) {
           $master->checkHealth();
         }
 
         if ($master->isSevered()) {
           self::setReadOnly(true, self::READONLY_SEVERED);
           break;
         }
       }
     }
 
     try {
       // See T13403. If we're starting up in "config optional" mode, suppress
       // messages about connection retries.
       if ($config_optional) {
         $database_source = @new PhabricatorConfigDatabaseSource('default');
       } else {
         $database_source = new PhabricatorConfigDatabaseSource('default');
       }
 
       $database_source->setName(pht('Database'));
 
       $stack->pushSource($database_source);
     } catch (AphrontSchemaQueryException $exception) {
       // If the database is not available, just skip this configuration
       // source. This happens during `bin/storage upgrade`, `bin/conf` before
       // schema setup, etc.
     } catch (PhabricatorClusterStrandedException $ex) {
       // This means we can't connect to any database host. That's fine as
       // long as we're running a setup script like `bin/storage`.
       if (!$config_optional) {
         throw $ex;
       }
     }
 
     // Drop the config cache one final time to make sure we're getting clean
     // reads now that we've finished building the stack.
     self::dropConfigCache();
   }
 
   public static function repairConfig($key, $value) {
     if (!self::$repairSource) {
       self::$repairSource = id(new PhabricatorConfigDictionarySource(array()))
         ->setName(pht('Repaired Config'));
       self::$sourceStack->pushSource(self::$repairSource);
     }
     self::$repairSource->setKeys(array($key => $value));
     self::dropConfigCache();
   }
 
   public static function overrideConfig($key, $value) {
     if (!self::$overrideSource) {
       self::$overrideSource = id(new PhabricatorConfigDictionarySource(array()))
         ->setName(pht('Overridden Config'));
       self::$sourceStack->pushSource(self::$overrideSource);
     }
     self::$overrideSource->setKeys(array($key => $value));
     self::dropConfigCache();
   }
 
   public static function getUnrepairedEnvConfig($key, $default = null) {
     foreach (self::$sourceStack->getStack() as $source) {
       if ($source === self::$repairSource) {
         continue;
       }
       $result = $source->getKeys(array($key));
       if ($result) {
         return $result[$key];
       }
     }
     return $default;
   }
 
   public static function getSelectedEnvironmentName() {
     $env_var = 'PHABRICATOR_ENV';
 
     $env = idx($_SERVER, $env_var);
 
     if (!$env) {
       $env = getenv($env_var);
     }
 
     if (!$env) {
       $env = idx($_ENV, $env_var);
     }
 
     if (!$env) {
       $root = dirname(phutil_get_library_root('phabricator'));
       $path = $root.'/conf/local/ENVIRONMENT';
       if (Filesystem::pathExists($path)) {
         $env = trim(Filesystem::readFile($path));
       }
     }
 
     return $env;
   }
 
 
 /* -(  Reading Configuration  )---------------------------------------------- */
 
 
   /**
    * Get the current configuration setting for a given key.
    *
    * If the key is not found, then throw an Exception.
    *
    * @task read
    */
   public static function getEnvConfig($key) {
     if (!self::$sourceStack) {
       throw new Exception(
         pht(
           'Trying to read configuration "%s" before configuration has been '.
           'initialized.',
           $key));
     }
 
     if (isset(self::$cache[$key])) {
       return self::$cache[$key];
     }
 
     if (array_key_exists($key, self::$cache)) {
       return self::$cache[$key];
     }
 
     $result = self::$sourceStack->getKeys(array($key));
     if (array_key_exists($key, $result)) {
       self::$cache[$key] = $result[$key];
       return $result[$key];
     } else {
       throw new Exception(
         pht(
           "No config value specified for key '%s'.",
           $key));
     }
   }
 
   /**
    * Get the current configuration setting for a given key. If the key
    * does not exist, return a default value instead of throwing. This is
    * primarily useful for migrations involving keys which are slated for
    * removal.
    *
    * @task read
    */
   public static function getEnvConfigIfExists($key, $default = null) {
     try {
       return self::getEnvConfig($key);
     } catch (Exception $ex) {
       return $default;
     }
   }
 
 
   /**
    * Get the fully-qualified URI for a path.
    *
    * @task read
    */
   public static function getURI($path) {
     return rtrim(self::getAnyBaseURI(), '/').$path;
   }
 
 
   /**
    * Get the fully-qualified production URI for a path.
    *
    * @task read
    */
   public static function getProductionURI($path) {
     // If we're passed a URI which already has a domain, simply return it
     // unmodified. In particular, files may have URIs which point to a CDN
     // domain.
     $uri = new PhutilURI($path);
     if ($uri->getDomain()) {
       return $path;
     }
 
     $production_domain = self::getEnvConfig('phabricator.production-uri');
     if (!$production_domain) {
       $production_domain = self::getAnyBaseURI();
     }
     return rtrim($production_domain, '/').$path;
   }
 
 
   public static function isSelfURI($raw_uri) {
     $uri = new PhutilURI($raw_uri);
 
     $host = $uri->getDomain();
     if (!phutil_nonempty_string($host)) {
       return true;
     }
 
     $host = phutil_utf8_strtolower($host);
 
     $self_map = self::getSelfURIMap();
     return isset($self_map[$host]);
   }
 
   private static function getSelfURIMap() {
     $self_uris = array();
     $self_uris[] = self::getProductionURI('/');
     $self_uris[] = self::getURI('/');
 
     $allowed_uris = self::getEnvConfig('phabricator.allowed-uris');
     foreach ($allowed_uris as $allowed_uri) {
       $self_uris[] = $allowed_uri;
     }
 
     $self_map = array();
     foreach ($self_uris as $self_uri) {
       $host = id(new PhutilURI($self_uri))->getDomain();
       if (!phutil_nonempty_string($host)) {
         continue;
       }
 
       $host = phutil_utf8_strtolower($host);
       $self_map[$host] = $host;
     }
 
     return $self_map;
   }
 
   /**
    * Get the fully-qualified production URI for a static resource path.
    *
    * @task read
    */
   public static function getCDNURI($path) {
     $alt = self::getEnvConfig('security.alternate-file-domain');
     if (!$alt) {
       $alt = self::getAnyBaseURI();
     }
     $uri = new PhutilURI($alt);
     $uri->setPath($path);
     return (string)$uri;
   }
 
 
   /**
    * Get the fully-qualified production URI for a documentation resource.
    *
    * @task read
    */
   public static function getDoclink($resource, $type = 'article') {
     $params = array(
       'name' => $resource,
       'type' => $type,
       'jump' => true,
     );
 
     $uri = new PhutilURI(
       'https://we.phorge.it/diviner/find/',
       $params);
 
     return phutil_string_cast($uri);
   }
 
 
   /**
    * Build a concrete object from a configuration key.
    *
    * @task read
    */
   public static function newObjectFromConfig($key, $args = array()) {
     $class = self::getEnvConfig($key);
     return newv($class, $args);
   }
 
   public static function getAnyBaseURI() {
     $base_uri = self::getEnvConfig('phabricator.base-uri');
 
     if (!$base_uri) {
       $base_uri = self::getRequestBaseURI();
     }
 
     if (!$base_uri) {
       throw new Exception(
         pht(
           "Define '%s' in your configuration to continue.",
           'phabricator.base-uri'));
     }
 
     return $base_uri;
   }
 
   public static function getRequestBaseURI() {
     return self::$requestBaseURI;
   }
 
   public static function setRequestBaseURI($uri) {
     self::$requestBaseURI = $uri;
   }
 
   public static function isReadOnly() {
     if (self::$readOnly !== null) {
       return self::$readOnly;
     }
     return self::getEnvConfig('cluster.read-only');
   }
 
   public static function setReadOnly($read_only, $reason) {
     self::$readOnly = $read_only;
     self::$readOnlyReason = $reason;
   }
 
   public static function getReadOnlyMessage() {
     $reason = self::getReadOnlyReason();
     switch ($reason) {
       case self::READONLY_MASTERLESS:
         return pht(
           'This server is in read-only mode (no writable database '.
           'is configured).');
       case self::READONLY_UNREACHABLE:
         return pht(
           'This server is in read-only mode (unreachable master).');
       case self::READONLY_SEVERED:
         return pht(
           'This server is in read-only mode (major interruption).');
     }
 
     return pht('This server is in read-only mode.');
   }
 
   public static function getReadOnlyURI() {
     return urisprintf(
       '/readonly/%s/',
       self::getReadOnlyReason());
   }
 
   public static function getReadOnlyReason() {
     if (!self::isReadOnly()) {
       return null;
     }
 
     if (self::$readOnlyReason !== null) {
       return self::$readOnlyReason;
     }
 
     return self::READONLY_CONFIG;
   }
 
 
 /* -(  Unit Test Support  )-------------------------------------------------- */
 
 
   /**
    * @task test
    */
   public static function beginScopedEnv() {
     return new PhabricatorScopedEnv(self::pushTestEnvironment());
   }
 
 
   /**
    * @task test
    */
   private static function pushTestEnvironment() {
     self::dropConfigCache();
     $source = new PhabricatorConfigDictionarySource(array());
     self::$sourceStack->pushSource($source);
     return spl_object_hash($source);
   }
 
 
   /**
    * @task test
    */
   public static function popTestEnvironment($key) {
     self::dropConfigCache();
     $source = self::$sourceStack->popSource();
     $stack_key = spl_object_hash($source);
     if ($stack_key !== $key) {
       self::$sourceStack->pushSource($source);
       throw new Exception(
         pht(
           'Scoped environments were destroyed in a different order than they '.
           'were initialized.'));
     }
   }
 
 
 /* -(  URI Validation  )----------------------------------------------------- */
 
 
   /**
    * Detect if a URI satisfies either @{method:isValidLocalURIForLink} or
    * @{method:isValidRemoteURIForLink}, i.e. is a page on this server or the
    * URI of some other resource which has a valid protocol. This rejects
    * garbage URIs and URIs with protocols which do not appear in the
    * `uri.allowed-protocols` configuration, notably 'javascript:' URIs.
    *
    * NOTE: This method is generally intended to reject URIs which it may be
    * unsafe to put in an "href" link attribute.
    *
-   * @param string URI to test.
+   * @param string $uri URI to test.
    * @return bool True if the URI identifies a web resource.
    * @task uri
    */
   public static function isValidURIForLink($uri) {
     return self::isValidLocalURIForLink($uri) ||
            self::isValidRemoteURIForLink($uri);
   }
 
 
   /**
    * Detect if a URI identifies some page on this server.
    *
    * NOTE: This method is generally intended to reject URIs which it may be
    * unsafe to issue a "Location:" redirect to.
    *
-   * @param string URI to test.
+   * @param string $uri URI to test.
    * @return bool True if the URI identifies a local page.
    * @task uri
    */
   public static function isValidLocalURIForLink($uri) {
     $uri = (string)$uri;
 
     if (!phutil_nonempty_string($uri)) {
       return false;
     }
 
     if (preg_match('/\s/', $uri)) {
       // PHP hasn't been vulnerable to header injection attacks for a bunch of
       // years, but we can safely reject these anyway since they're never valid.
       return false;
     }
 
     // Chrome (at a minimum) interprets backslashes in Location headers and the
     // URL bar as forward slashes. This is probably intended to reduce user
     // error caused by confusion over which key is "forward slash" vs "back
     // slash".
     //
     // However, it means a URI like "/\evil.com" is interpreted like
     // "//evil.com", which is a protocol relative remote URI.
     //
     // Since we currently never generate URIs with backslashes in them, reject
     // these unconditionally rather than trying to figure out how browsers will
     // interpret them.
     if (preg_match('/\\\\/', $uri)) {
       return false;
     }
 
     // Valid URIs must begin with '/', followed by the end of the string or some
     // other non-'/' character. This rejects protocol-relative URIs like
     // "//evil.com/evil_stuff/".
     return (bool)preg_match('@^/([^/]|$)@', $uri);
   }
 
 
   /**
    * Detect if a URI identifies some valid linkable remote resource.
    *
-   * @param string URI to test.
+   * @param string $uri URI to test.
    * @return bool True if a URI identifies a remote resource with an allowed
    *              protocol.
    * @task uri
    */
   public static function isValidRemoteURIForLink($uri) {
     try {
       self::requireValidRemoteURIForLink($uri);
       return true;
     } catch (Exception $ex) {
       return false;
     }
   }
 
 
   /**
    * Detect if a URI identifies a valid linkable remote resource, throwing a
    * detailed message if it does not.
    *
    * A valid linkable remote resource can be safely linked or redirected to.
    * This is primarily a protocol whitelist check.
    *
-   * @param string URI to test.
+   * @param string $raw_uri URI to test.
    * @return void
    * @task uri
    */
   public static function requireValidRemoteURIForLink($raw_uri) {
     $uri = new PhutilURI($raw_uri);
 
     $proto = $uri->getProtocol();
     if (!$proto) {
       throw new Exception(
         pht(
           'URI "%s" is not a valid linkable resource. A valid linkable '.
           'resource URI must specify a protocol.',
           $raw_uri));
     }
 
     $protocols = self::getEnvConfig('uri.allowed-protocols');
     if (!isset($protocols[$proto])) {
       throw new Exception(
         pht(
           'URI "%s" is not a valid linkable resource. A valid linkable '.
           'resource URI must use one of these protocols: %s.',
           $raw_uri,
           implode(', ', array_keys($protocols))));
     }
 
     $domain = $uri->getDomain();
     if (!$domain) {
       throw new Exception(
         pht(
           'URI "%s" is not a valid linkable resource. A valid linkable '.
           'resource URI must specify a domain.',
           $raw_uri));
     }
   }
 
 
   /**
    * Detect if a URI identifies a valid fetchable remote resource.
    *
-   * @param string URI to test.
-   * @param list<string> Allowed protocols.
+   * @param string $uri URI to test.
+   * @param list<string> $protocols Allowed protocols.
    * @return bool True if the URI is a valid fetchable remote resource.
    * @task uri
    */
   public static function isValidRemoteURIForFetch($uri, array $protocols) {
     try {
       self::requireValidRemoteURIForFetch($uri, $protocols);
       return true;
     } catch (Exception $ex) {
       return false;
     }
   }
 
 
   /**
    * Detect if a URI identifies a valid fetchable remote resource, throwing
    * a detailed message if it does not.
    *
    * A valid fetchable remote resource can be safely fetched using a request
    * originating on this server. This is a primarily an address check against
    * the outbound address blacklist.
    *
-   * @param string URI to test.
-   * @param list<string> Allowed protocols.
+   * @param string $raw_uri URI to test.
+   * @param list<string> $protocols Allowed protocols.
    * @return pair<string, string> Pre-resolved URI and domain.
    * @task uri
    */
   public static function requireValidRemoteURIForFetch(
     $raw_uri,
     array $protocols) {
 
     $uri = new PhutilURI($raw_uri);
 
     $proto = $uri->getProtocol();
     if (!$proto) {
       throw new Exception(
         pht(
           'URI "%s" is not a valid fetchable resource. A valid fetchable '.
           'resource URI must specify a protocol.',
           $raw_uri));
     }
 
     $protocols = array_fuse($protocols);
     if (!isset($protocols[$proto])) {
       throw new Exception(
         pht(
           'URI "%s" is not a valid fetchable resource. A valid fetchable '.
           'resource URI must use one of these protocols: %s.',
           $raw_uri,
           implode(', ', array_keys($protocols))));
     }
 
     $domain = $uri->getDomain();
     if (!$domain) {
       throw new Exception(
         pht(
           'URI "%s" is not a valid fetchable resource. A valid fetchable '.
           'resource URI must specify a domain.',
           $raw_uri));
     }
 
     $addresses = gethostbynamel($domain);
     if (!$addresses) {
       throw new Exception(
         pht(
           'URI "%s" is not a valid fetchable resource. The domain "%s" could '.
           'not be resolved.',
           $raw_uri,
           $domain));
     }
 
     foreach ($addresses as $address) {
       if (self::isBlacklistedOutboundAddress($address)) {
         throw new Exception(
           pht(
             'URI "%s" is not a valid fetchable resource. The domain "%s" '.
             'resolves to the address "%s", which is blacklisted for '.
             'outbound requests.',
             $raw_uri,
             $domain,
             $address));
       }
     }
 
     $resolved_uri = clone $uri;
     $resolved_uri->setDomain(head($addresses));
 
     return array($resolved_uri, $domain);
   }
 
 
   /**
    * Determine if an IP address is in the outbound address blacklist.
    *
-   * @param string IP address.
+   * @param string $address IP address.
    * @return bool True if the address is blacklisted.
    */
   public static function isBlacklistedOutboundAddress($address) {
     $blacklist = self::getEnvConfig('security.outbound-blacklist');
 
     return PhutilCIDRList::newList($blacklist)->containsAddress($address);
   }
 
   public static function isClusterRemoteAddress() {
     $cluster_addresses = self::getEnvConfig('cluster.addresses');
     if (!$cluster_addresses) {
       return false;
     }
 
     $address = self::getRemoteAddress();
     if (!$address) {
       throw new Exception(
         pht(
           'Unable to test remote address against cluster whitelist: '.
           'REMOTE_ADDR is not defined or not valid.'));
     }
 
     return self::isClusterAddress($address);
   }
 
   public static function isClusterAddress($address) {
     $cluster_addresses = self::getEnvConfig('cluster.addresses');
     if (!$cluster_addresses) {
       throw new Exception(
         pht(
           'This server is not configured to serve cluster requests. '.
           'Set `cluster.addresses` in the configuration to whitelist '.
           'cluster hosts before sending requests that use a cluster '.
           'authentication mechanism.'));
     }
 
     return PhutilCIDRList::newList($cluster_addresses)
       ->containsAddress($address);
   }
 
   public static function getRemoteAddress() {
     $address = idx($_SERVER, 'REMOTE_ADDR');
     if (!$address) {
       return null;
     }
 
     try {
       return PhutilIPAddress::newAddress($address);
     } catch (Exception $ex) {
       return null;
     }
   }
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * @task internal
    */
   public static function envConfigExists($key) {
     return array_key_exists($key, self::$sourceStack->getKeys(array($key)));
   }
 
 
   /**
    * @task internal
    */
   public static function getAllConfigKeys() {
     return self::$sourceStack->getAllKeys();
   }
 
   public static function getConfigSourceStack() {
     return self::$sourceStack;
   }
 
   /**
    * @task internal
    */
   public static function overrideTestEnvConfig($stack_key, $key, $value) {
     $tmp = array();
 
     // If we don't have the right key, we'll throw when popping the last
     // source off the stack.
     do {
       $source = self::$sourceStack->popSource();
       array_unshift($tmp, $source);
       if (spl_object_hash($source) == $stack_key) {
         $source->setKeys(array($key => $value));
         break;
       }
     } while (true);
 
     foreach ($tmp as $source) {
       self::$sourceStack->pushSource($source);
     }
 
     self::dropConfigCache();
   }
 
   private static function dropConfigCache() {
     self::$cache = array();
   }
 
   private static function resetUmask() {
     // Reset the umask to the common standard umask. The umask controls default
     // permissions when files are created and propagates to subprocesses.
 
     // "022" is the most common umask, but sometimes it is set to something
     // unusual by the calling environment.
 
     // Since various things rely on this umask to work properly and we are
     // not aware of any legitimate reasons to adjust it, unconditionally
     // normalize it until such reasons arise. See T7475 for discussion.
     umask(022);
   }
 
 
   /**
    * Get the path to an empty directory which is readable by all of the system
    * user accounts that Phabricator acts as.
    *
    * In some cases, a binary needs some valid HOME or CWD to continue, but not
    * all user accounts have valid home directories and even if they do they
    * may not be readable after a `sudo` operation.
    *
    * @return string Path to an empty directory suitable for use as a CWD.
    */
   public static function getEmptyCWD() {
     $root = dirname(phutil_get_library_root('phabricator'));
     return $root.'/support/empty/';
   }
 
 
 }
diff --git a/src/infrastructure/env/PhabricatorScopedEnv.php b/src/infrastructure/env/PhabricatorScopedEnv.php
index 3bec720ae4..80a9e5893a 100644
--- a/src/infrastructure/env/PhabricatorScopedEnv.php
+++ b/src/infrastructure/env/PhabricatorScopedEnv.php
@@ -1,59 +1,59 @@
 <?php
 
 /**
  * Scope guard to hold a temporary environment. See @{class:PhabricatorEnv} for
  * instructions on use.
  *
  * @task internal Internals
  * @task override Overriding Environment Configuration
  */
 final class PhabricatorScopedEnv extends Phobject {
 
   private $key;
   private $isPopped = false;
 
 /* -(  Overriding Environment Configuration  )------------------------------- */
 
   /**
    * Override a configuration key in this scope, setting it to a new value.
    *
-   * @param  string Key to override.
-   * @param  wild   New value.
+   * @param  string $key Key to override.
+   * @param  wild   $value New value.
    * @return this
    *
    * @task override
    */
   public function overrideEnvConfig($key, $value) {
     PhabricatorEnv::overrideTestEnvConfig(
       $this->key,
       $key,
       $value);
     return $this;
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * @task internal
    */
   public function __construct($stack_key) {
     $this->key = $stack_key;
   }
 
 
   /**
    * Release the scoped environment.
    *
    * @return void
    * @task internal
    */
   public function __destruct() {
     if (!$this->isPopped) {
       PhabricatorEnv::popTestEnvironment($this->key);
       $this->isPopped = true;
     }
   }
 
 }
diff --git a/src/infrastructure/markup/PhabricatorMarkupEngine.php b/src/infrastructure/markup/PhabricatorMarkupEngine.php
index 84503048d0..89f03977f8 100644
--- a/src/infrastructure/markup/PhabricatorMarkupEngine.php
+++ b/src/infrastructure/markup/PhabricatorMarkupEngine.php
@@ -1,743 +1,745 @@
 <?php
 
 /**
  * Manages markup engine selection, configuration, application, caching and
  * pipelining.
  *
  * @{class:PhabricatorMarkupEngine} can be used to render objects which
  * implement @{interface:PhabricatorMarkupInterface} in a batched, cache-aware
  * way. For example, if you have a list of comments written in remarkup (and
  * the objects implement the correct interface) you can render them by first
  * building an engine and adding the fields with @{method:addObject}.
  *
  *   $field  = 'field:body'; // Field you want to render. Each object exposes
  *                           // one or more fields of markup.
  *
  *   $engine = new PhabricatorMarkupEngine();
  *   foreach ($comments as $comment) {
  *     $engine->addObject($comment, $field);
  *   }
  *
  * Now, call @{method:process} to perform the actual cache/rendering
  * step. This is a heavyweight call which does batched data access and
  * transforms the markup into output.
  *
  *   $engine->process();
  *
  * Finally, do something with the results:
  *
  *   $results = array();
  *   foreach ($comments as $comment) {
  *     $results[] = $engine->getOutput($comment, $field);
  *   }
  *
  * If you have a single object to render, you can use the convenience method
  * @{method:renderOneObject}.
  *
  * @task markup Markup Pipeline
  * @task engine Engine Construction
  */
 final class PhabricatorMarkupEngine extends Phobject {
 
   private $objects = array();
   private $viewer;
   private $contextObject;
   private $version = 21;
   private $engineCaches = array();
   private $auxiliaryConfig = array();
 
   private static $engineStack = array();
 
 
 /* -(  Markup Pipeline  )---------------------------------------------------- */
 
 
   /**
    * Convenience method for pushing a single object through the markup
    * pipeline.
    *
-   * @param PhabricatorMarkupInterface  The object to render.
-   * @param string                      The field to render.
-   * @param PhabricatorUser             User viewing the markup.
-   * @param object                      A context object for policy checks
+   * @param PhabricatorMarkupInterface  $object The object to render.
+   * @param string                      $field The field to render.
+   * @param PhabricatorUser             $viewer User viewing the markup.
+   * @param object?                     $context_object A context object for
+   *                                    policy checks.
    * @return string                     Marked up output.
    * @task markup
    */
   public static function renderOneObject(
     PhabricatorMarkupInterface $object,
     $field,
     PhabricatorUser $viewer,
     $context_object = null) {
     return id(new PhabricatorMarkupEngine())
       ->setViewer($viewer)
       ->setContextObject($context_object)
       ->addObject($object, $field)
       ->process()
       ->getOutput($object, $field);
   }
 
 
   /**
    * Queue an object for markup generation when @{method:process} is
    * called. You can retrieve the output later with @{method:getOutput}.
    *
-   * @param PhabricatorMarkupInterface  The object to render.
-   * @param string                      The field to render.
+   * @param PhabricatorMarkupInterface  $object The object to render.
+   * @param string                      $field The field to render.
    * @return this
    * @task markup
    */
   public function addObject(PhabricatorMarkupInterface $object, $field) {
     $key = $this->getMarkupFieldKey($object, $field);
     $this->objects[$key] = array(
       'object' => $object,
       'field'  => $field,
     );
 
     return $this;
   }
 
 
   /**
    * Process objects queued with @{method:addObject}. You can then retrieve
    * the output with @{method:getOutput}.
    *
    * @return this
    * @task markup
    */
   public function process() {
     self::$engineStack[] = $this;
 
     try {
       $result = $this->execute();
     } finally {
       array_pop(self::$engineStack);
     }
 
     return $result;
   }
 
   public static function isRenderingEmbeddedContent() {
     // See T13678. This prevents cycles when rendering embedded content that
     // itself has remarkup fields.
     return (count(self::$engineStack) > 1);
   }
 
   private function execute() {
     $keys = array();
     foreach ($this->objects as $key => $info) {
       if (!isset($info['markup'])) {
         $keys[] = $key;
       }
     }
 
     if (!$keys) {
       return $this;
     }
 
     $objects = array_select_keys($this->objects, $keys);
 
     // Build all the markup engines. We need an engine for each field whether
     // we have a cache or not, since we still need to postprocess the cache.
     $engines = array();
     foreach ($objects as $key => $info) {
       $engines[$key] = $info['object']->newMarkupEngine($info['field']);
       $engines[$key]->setConfig('viewer', $this->viewer);
       $engines[$key]->setConfig('contextObject', $this->contextObject);
 
       foreach ($this->auxiliaryConfig as $aux_key => $aux_value) {
         $engines[$key]->setConfig($aux_key, $aux_value);
       }
     }
 
     // Load or build the preprocessor caches.
     $blocks = $this->loadPreprocessorCaches($engines, $objects);
     $blocks = mpull($blocks, 'getCacheData');
 
     $this->engineCaches = $blocks;
 
     // Finalize the output.
     foreach ($objects as $key => $info) {
       $engine = $engines[$key];
       $field = $info['field'];
       $object = $info['object'];
 
       $output = $engine->postprocessText($blocks[$key]);
       $output = $object->didMarkupText($field, $output, $engine);
       $this->objects[$key]['output'] = $output;
     }
 
     return $this;
   }
 
 
   /**
    * Get the output of markup processing for a field queued with
    * @{method:addObject}. Before you can call this method, you must call
    * @{method:process}.
    *
-   * @param PhabricatorMarkupInterface  The object to retrieve.
-   * @param string                      The field to retrieve.
+   * @param PhabricatorMarkupInterface  $object The object to retrieve.
+   * @param string                      $field The field to retrieve.
    * @return string                     Processed output.
    * @task markup
    */
   public function getOutput(PhabricatorMarkupInterface $object, $field) {
     $key = $this->getMarkupFieldKey($object, $field);
     $this->requireKeyProcessed($key);
 
     return $this->objects[$key]['output'];
   }
 
 
   /**
    * Retrieve engine metadata for a given field.
    *
-   * @param PhabricatorMarkupInterface  The object to retrieve.
-   * @param string                      The field to retrieve.
-   * @param string                      The engine metadata field to retrieve.
-   * @param wild                        Optional default value.
+   * @param PhabricatorMarkupInterface  $object The object to retrieve.
+   * @param string                      $field The field to retrieve.
+   * @param string                      $metadata_key The engine metadata field
+   *                                    to retrieve.
+   * @param wild?                       $default Optional default value.
    * @task markup
    */
   public function getEngineMetadata(
     PhabricatorMarkupInterface $object,
     $field,
     $metadata_key,
     $default = null) {
 
     $key = $this->getMarkupFieldKey($object, $field);
     $this->requireKeyProcessed($key);
 
     return idx($this->engineCaches[$key]['metadata'], $metadata_key, $default);
   }
 
 
   /**
    * @task markup
    */
   private function requireKeyProcessed($key) {
     if (empty($this->objects[$key])) {
       throw new Exception(
         pht(
           "Call %s before using results (key = '%s').",
           'addObject()',
           $key));
     }
 
     if (!isset($this->objects[$key]['output'])) {
       throw new PhutilInvalidStateException('process');
     }
   }
 
 
   /**
    * @task markup
    */
   private function getMarkupFieldKey(
     PhabricatorMarkupInterface $object,
     $field) {
 
     static $custom;
     if ($custom === null) {
       $custom = array_merge(
         self::loadCustomInlineRules(),
         self::loadCustomBlockRules());
 
       $custom = mpull($custom, 'getRuleVersion', null);
       ksort($custom);
       $custom = PhabricatorHash::digestForIndex(serialize($custom));
     }
 
     return $object->getMarkupFieldKey($field).'@'.$this->version.'@'.$custom;
   }
 
 
   /**
    * @task markup
    */
   private function loadPreprocessorCaches(array $engines, array $objects) {
     $blocks = array();
 
     $use_cache = array();
     foreach ($objects as $key => $info) {
       if ($info['object']->shouldUseMarkupCache($info['field'])) {
         $use_cache[$key] = true;
       }
     }
 
     if ($use_cache) {
       try {
         $blocks = id(new PhabricatorMarkupCache())->loadAllWhere(
           'cacheKey IN (%Ls)',
           array_keys($use_cache));
         $blocks = mpull($blocks, null, 'getCacheKey');
       } catch (Exception $ex) {
         phlog($ex);
       }
     }
 
     $is_readonly = PhabricatorEnv::isReadOnly();
 
     foreach ($objects as $key => $info) {
       // False check in case MySQL doesn't support unicode characters
       // in the string (T1191), resulting in unserialize returning false.
       if (isset($blocks[$key]) && $blocks[$key]->getCacheData() !== false) {
         // If we already have a preprocessing cache, we don't need to rebuild
         // it.
         continue;
       }
 
       $text = $info['object']->getMarkupText($info['field']);
       $data = $engines[$key]->preprocessText($text);
 
       // NOTE: This is just debugging information to help sort out cache issues.
       // If one machine is misconfigured and poisoning caches you can use this
       // field to hunt it down.
 
       $metadata = array(
         'host' => php_uname('n'),
       );
 
       $blocks[$key] = id(new PhabricatorMarkupCache())
         ->setCacheKey($key)
         ->setCacheData($data)
         ->setMetadata($metadata);
 
       if (isset($use_cache[$key]) && !$is_readonly) {
         // This is just filling a cache and always safe, even on a read pathway.
         $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
           $blocks[$key]->replace();
         unset($unguarded);
       }
     }
 
     return $blocks;
   }
 
 
   /**
    * Set the viewing user. Used to implement object permissions.
    *
-   * @param PhabricatorUser The viewing user.
+   * @param PhabricatorUser $viewer The viewing user.
    * @return this
    * @task markup
    */
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   /**
    * Set the context object. Used to implement object permissions.
    *
-   * @param The object in which context this remarkup is used.
+   * @param $object The object in which context this remarkup is used.
    * @return this
    * @task markup
    */
   public function setContextObject($object) {
     $this->contextObject = $object;
     return $this;
   }
 
   public function setAuxiliaryConfig($key, $value) {
     // TODO: This is gross and should be removed. Avoid use.
     $this->auxiliaryConfig[$key] = $value;
     return $this;
   }
 
 
 /* -(  Engine Construction  )------------------------------------------------ */
 
 
 
   /**
    * @task engine
    */
   public static function newManiphestMarkupEngine() {
     return self::newMarkupEngine(array(
     ));
   }
 
 
   /**
    * @task engine
    */
   public static function newPhrictionMarkupEngine() {
     return self::newMarkupEngine(array(
       'header.generate-toc' => true,
     ));
   }
 
 
   /**
    * @task engine
    */
   public static function newPhameMarkupEngine() {
     return self::newMarkupEngine(
       array(
         'macros' => false,
         'uri.full' => true,
         'uri.same-window' => true,
         'uri.base' => PhabricatorEnv::getURI('/'),
       ));
   }
 
 
   /**
    * @task engine
    */
   public static function newFeedMarkupEngine() {
     return self::newMarkupEngine(
       array(
         'macros'      => false,
         'youtube'     => false,
       ));
   }
 
   /**
    * @task engine
    */
   public static function newCalendarMarkupEngine() {
     return self::newMarkupEngine(array(
     ));
   }
 
 
   /**
    * @task engine
    */
   public static function newDifferentialMarkupEngine(array $options = array()) {
     return self::newMarkupEngine(array(
       'differential.diff' => idx($options, 'differential.diff'),
     ));
   }
 
 
   /**
    * @task engine
    */
   public static function newDiffusionMarkupEngine(array $options = array()) {
     return self::newMarkupEngine(array(
       'header.generate-toc' => true,
     ));
   }
 
   /**
    * @task engine
    */
   public static function getEngine($ruleset = 'default') {
     static $engines = array();
     if (isset($engines[$ruleset])) {
       return $engines[$ruleset];
     }
 
     $engine = null;
     switch ($ruleset) {
       case 'default':
         $engine = self::newMarkupEngine(array());
         break;
       case 'feed':
         $engine = self::newMarkupEngine(array());
         $engine->setConfig('autoplay.disable', true);
         break;
       case 'nolinebreaks':
         $engine = self::newMarkupEngine(array());
         $engine->setConfig('preserve-linebreaks', false);
         break;
       case 'diffusion-readme':
         $engine = self::newMarkupEngine(array());
         $engine->setConfig('preserve-linebreaks', false);
         $engine->setConfig('header.generate-toc', true);
         break;
       case 'diviner':
         $engine = self::newMarkupEngine(array());
         $engine->setConfig('preserve-linebreaks', false);
   //    $engine->setConfig('diviner.renderer', new DivinerDefaultRenderer());
         $engine->setConfig('header.generate-toc', true);
         break;
       case 'extract':
         // Engine used for reference/edge extraction. Turn off anything which
         // is slow and doesn't change reference extraction.
         $engine = self::newMarkupEngine(array());
         $engine->setConfig('pygments.enabled', false);
         break;
       default:
         throw new Exception(pht('Unknown engine ruleset: %s!', $ruleset));
     }
 
     $engines[$ruleset] = $engine;
     return $engine;
   }
 
   /**
    * @task engine
    */
   private static function getMarkupEngineDefaultConfiguration() {
     return array(
       'pygments'      => PhabricatorEnv::getEnvConfig('pygments.enabled'),
       'youtube'       => PhabricatorEnv::getEnvConfig(
         'remarkup.enable-embedded-youtube'),
       'differential.diff' => null,
       'header.generate-toc' => false,
       'macros'        => true,
       'uri.allowed-protocols' => PhabricatorEnv::getEnvConfig(
         'uri.allowed-protocols'),
       'uri.full' => false,
       'syntax-highlighter.engine' => PhabricatorEnv::getEnvConfig(
         'syntax-highlighter.engine'),
       'preserve-linebreaks' => true,
     );
   }
 
 
   /**
    * @task engine
    */
   public static function newMarkupEngine(array $options) {
     $options += self::getMarkupEngineDefaultConfiguration();
 
     $engine = new PhutilRemarkupEngine();
 
     $engine->setConfig('preserve-linebreaks', $options['preserve-linebreaks']);
 
     $engine->setConfig('pygments.enabled', $options['pygments']);
     $engine->setConfig(
       'uri.allowed-protocols',
       $options['uri.allowed-protocols']);
     $engine->setConfig('differential.diff', $options['differential.diff']);
     $engine->setConfig('header.generate-toc', $options['header.generate-toc']);
     $engine->setConfig(
       'syntax-highlighter.engine',
       $options['syntax-highlighter.engine']);
 
     $style_map = id(new PhabricatorDefaultSyntaxStyle())
       ->getRemarkupStyleMap();
     $engine->setConfig('phutil.codeblock.style-map', $style_map);
 
     $engine->setConfig('uri.full', $options['uri.full']);
 
     if (isset($options['uri.base'])) {
       $engine->setConfig('uri.base', $options['uri.base']);
     }
 
     if (isset($options['uri.same-window'])) {
       $engine->setConfig('uri.same-window', $options['uri.same-window']);
     }
 
     $rules = array();
     $rules[] = new PhutilRemarkupEscapeRemarkupRule();
     $rules[] = new PhutilRemarkupEvalRule();
     $rules[] = new PhutilRemarkupMonospaceRule();
     $rules[] = new PhutilRemarkupHexColorCodeRule();
 
     $rules[] = new PhutilRemarkupDocumentLinkRule();
     $rules[] = new PhabricatorNavigationRemarkupRule();
     $rules[] = new PhabricatorKeyboardRemarkupRule();
     $rules[] = new PhabricatorConfigRemarkupRule();
 
     if ($options['youtube']) {
       $rules[] = new PhabricatorYoutubeRemarkupRule();
     }
 
     $rules[] = new PhabricatorIconRemarkupRule();
     $rules[] = new PhabricatorEmojiRemarkupRule();
     $rules[] = new PhabricatorHandleRemarkupRule();
 
     $applications = PhabricatorApplication::getAllInstalledApplications();
     foreach ($applications as $application) {
       foreach ($application->getRemarkupRules() as $rule) {
         $rules[] = $rule;
       }
     }
 
     $rules[] = new PhutilRemarkupHyperlinkRule();
 
     if ($options['macros']) {
       $rules[] = new PhabricatorImageMacroRemarkupRule();
       $rules[] = new PhabricatorMemeRemarkupRule();
     }
 
     $rules[] = new PhutilRemarkupBoldRule();
     $rules[] = new PhutilRemarkupItalicRule();
     $rules[] = new PhutilRemarkupDelRule();
     $rules[] = new PhutilRemarkupUnderlineRule();
     $rules[] = new PhutilRemarkupHighlightRule();
     $rules[] = new PhutilRemarkupAnchorRule();
 
     foreach (self::loadCustomInlineRules() as $rule) {
       $rules[] = clone $rule;
     }
 
     $blocks = array();
     $blocks[] = new PhutilRemarkupQuotesBlockRule();
     $blocks[] = new PhutilRemarkupReplyBlockRule();
     $blocks[] = new PhutilRemarkupLiteralBlockRule();
     $blocks[] = new PhutilRemarkupHeaderBlockRule();
     $blocks[] = new PhutilRemarkupHorizontalRuleBlockRule();
     $blocks[] = new PhutilRemarkupListBlockRule();
     $blocks[] = new PhutilRemarkupCodeBlockRule();
     $blocks[] = new PhutilRemarkupNoteBlockRule();
     $blocks[] = new PhutilRemarkupTableBlockRule();
     $blocks[] = new PhutilRemarkupSimpleTableBlockRule();
     $blocks[] = new PhutilRemarkupInterpreterBlockRule();
     $blocks[] = new PhutilRemarkupDefaultBlockRule();
 
     foreach (self::loadCustomBlockRules() as $rule) {
       $blocks[] = $rule;
     }
 
     foreach ($blocks as $block) {
       $block->setMarkupRules($rules);
     }
 
     $engine->setBlockRules($blocks);
 
     return $engine;
   }
 
   public static function extractPHIDsFromMentions(
     PhabricatorUser $viewer,
     array $content_blocks) {
 
     $mentions = array();
 
     $engine = self::newDifferentialMarkupEngine();
     $engine->setConfig('viewer', $viewer);
 
     foreach ($content_blocks as $content_block) {
       if ($content_block === null) {
         continue;
       }
 
       if (!strlen($content_block)) {
         continue;
       }
 
       $engine->markupText($content_block);
       $phids = $engine->getTextMetadata(
         PhabricatorMentionRemarkupRule::KEY_MENTIONED,
         array());
       $mentions += $phids;
     }
 
     return $mentions;
   }
 
   public static function extractFilePHIDsFromEmbeddedFiles(
     PhabricatorUser $viewer,
     array $content_blocks) {
     $files = array();
 
     $engine = self::newDifferentialMarkupEngine();
     $engine->setConfig('viewer', $viewer);
 
     foreach ($content_blocks as $content_block) {
       $engine->markupText($content_block);
       $phids = $engine->getTextMetadata(
         PhabricatorEmbedFileRemarkupRule::KEY_ATTACH_INTENT_FILE_PHIDS,
         array());
       foreach ($phids as $phid) {
         $files[$phid] = $phid;
       }
     }
 
     return array_values($files);
   }
 
   public static function summarizeSentence($corpus) {
     $corpus = trim($corpus);
     $blocks = preg_split('/\n+/', $corpus, 2);
     $block = head($blocks);
 
     $sentences = preg_split(
       '/\b([.?!]+)\B/u',
       $block,
       2,
       PREG_SPLIT_DELIM_CAPTURE);
 
     if (count($sentences) > 1) {
       $result = $sentences[0].$sentences[1];
     } else {
       $result = head($sentences);
     }
 
     return id(new PhutilUTF8StringTruncator())
       ->setMaximumGlyphs(128)
       ->truncateString($result);
   }
 
   /**
    * Produce a corpus summary, in a way that shortens the underlying text
    * without truncating it somewhere awkward.
    *
    * TODO: We could do a better job of this.
    *
-   * @param string  Remarkup corpus to summarize.
+   * @param string $corpus Remarkup corpus to summarize.
    * @return string Summarized corpus.
    */
   public static function summarize($corpus) {
 
     // Major goals here are:
     //  - Don't split in the middle of a character (utf-8).
     //  - Don't split in the middle of, e.g., **bold** text, since
     //    we end up with hanging '**' in the summary.
     //  - Try not to pick an image macro, header, embedded file, etc.
     //  - Hopefully don't return too much text. We don't explicitly limit
     //    this right now.
 
     $blocks = preg_split("/\n *\n\s*/", $corpus);
 
     $best = null;
     foreach ($blocks as $block) {
       // This is a test for normal spaces in the block, i.e. a heuristic to
       // distinguish standard paragraphs from things like image macros. It may
       // not work well for non-latin text. We prefer to summarize with a
       // paragraph of normal words over an image macro, if possible.
       $has_space = preg_match('/\w\s\w/', $block);
 
       // This is a test to find embedded images and headers. We prefer to
       // summarize with a normal paragraph over a header or an embedded object,
       // if possible.
       $has_embed = preg_match('/^[{=]/', $block);
 
       if ($has_space && !$has_embed) {
         // This seems like a good summary, so return it.
         return $block;
       }
 
       if (!$best) {
         // This is the first block we found; if everything is garbage just
         // use the first block.
         $best = $block;
       }
     }
 
     return $best;
   }
 
   private static function loadCustomInlineRules() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass('PhabricatorRemarkupCustomInlineRule')
       ->execute();
   }
 
   private static function loadCustomBlockRules() {
     return id(new PhutilClassMapQuery())
       ->setAncestorClass('PhabricatorRemarkupCustomBlockRule')
       ->execute();
   }
 
   public static function digestRemarkupContent($object, $content) {
     $parts = array();
     $parts[] = get_class($object);
 
     if ($object instanceof PhabricatorLiskDAO) {
       $parts[] = $object->getID();
     }
 
     $parts[] = $content;
 
     $message = implode("\n", $parts);
 
     return PhabricatorHash::digestWithNamedKey($message, 'remarkup');
   }
 
 }
diff --git a/src/infrastructure/markup/PhabricatorMarkupInterface.php b/src/infrastructure/markup/PhabricatorMarkupInterface.php
index f28c7ab50b..fdc39407b3 100644
--- a/src/infrastructure/markup/PhabricatorMarkupInterface.php
+++ b/src/infrastructure/markup/PhabricatorMarkupInterface.php
@@ -1,85 +1,85 @@
 <?php
 
 /**
  * An object which has one or more fields containing markup that can be
  * rendered into a display format. Commonly, the fields contain Remarkup and
  * are rendered into HTML. Implementing this interface allows you to render
  * objects through @{class:PhabricatorMarkupEngine} and benefit from caching
  * and pipelining infrastructure.
  *
  * An object may have several "fields" of markup. For example, Differential
  * revisions have a "summary" and a "test plan". In these cases, the `$field`
  * parameter is used to identify which field is being operated on. For simple
  * objects like comments, you might only have one field (say, "body"). In
  * these cases, the implementation can largely ignore the `$field` parameter.
  *
  * @task markup Markup Interface
  */
 interface PhabricatorMarkupInterface {
 
 
 /* -(  Markup Interface  )--------------------------------------------------- */
 
 
   /**
    * Get a key to identify this field. This should uniquely identify the block
    * of text to be rendered and be usable as a cache key. If the object has a
    * PHID, using the PHID and the field name is likely reasonable:
    *
    *   "{$phid}:{$field}"
    *
-   * @param string Field name.
+   * @param string $field Field name.
    * @return string Cache key up to 125 characters.
    *
    * @task markup
    */
   public function getMarkupFieldKey($field);
 
 
   /**
    * Build the engine the field should use.
    *
-   * @param string Field name.
+   * @param string $field Field name.
    * @return PhutilRemarkupEngine Markup engine to use.
    * @task markup
    */
   public function newMarkupEngine($field);
 
 
   /**
    * Return the contents of the specified field.
    *
-   * @param string Field name.
+   * @param string $field Field name.
    * @return string The raw markup contained in the field.
    * @task markup
    */
   public function getMarkupText($field);
 
 
   /**
    * Callback for final postprocessing of output. Normally, you can return
    * the output unmodified.
    *
-   * @param string Field name.
-   * @param string The finalized output of the engine.
-   * @param string The engine which generated the output.
+   * @param string $field Field name.
+   * @param string $output The finalized output of the engine.
+   * @param string $engine The engine which generated the output.
    * @return string Final output.
    * @task markup
    */
   public function didMarkupText(
     $field,
     $output,
     PhutilMarkupEngine $engine);
 
 
   /**
    * Determine if the engine should try to use the markup cache or not.
    * Generally you should use the cache for durable/permanent content but
    * should not use the cache for temporary/draft content.
    *
    * @return bool True to use the markup cache.
    * @task markup
    */
   public function shouldUseMarkupCache($field);
 
 }
diff --git a/src/infrastructure/markup/PhutilMarkupEngine.php b/src/infrastructure/markup/PhutilMarkupEngine.php
index e00ef15844..7f650a4417 100644
--- a/src/infrastructure/markup/PhutilMarkupEngine.php
+++ b/src/infrastructure/markup/PhutilMarkupEngine.php
@@ -1,32 +1,33 @@
 <?php
 
 abstract class PhutilMarkupEngine extends Phobject {
 
   /**
    * Set a configuration parameter which the engine can read to customize how
    * the text is marked up. This is a generic interface; consult the
    * documentation for specific rules and blocks for what options are available
    * for configuration.
    *
-   * @param   string  Key to set in the configuration dictionary.
-   * @param   string  Value to set.
+   * @param   string $key Key to set in the configuration dictionary.
+   * @param   string $value Value to set.
    * @return  this
    */
   abstract public function setConfig($key, $value);
 
   /**
    * After text has been marked up with @{method:markupText}, you can retrieve
    * any metadata the markup process generated by calling this method. This is
    * a generic interface that allows rules to export extra information about
    * text; consult the documentation for specific rules and blocks to see what
    * metadata may be available in your configuration.
    *
-   * @param   string  Key to retrieve from metadata.
-   * @param   mixed   Default value to return if the key is not available.
+   * @param   string  $key Key to retrieve from metadata.
+   * @param   mixed?  $default Default value to return if the key is not
+   *   available.
    * @return  mixed   Metadata property, or default value.
    */
   abstract public function getTextMetadata($key, $default = null);
 
   abstract public function markupText($text);
 
 }
diff --git a/src/infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php b/src/infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php
index 8763111dd1..8cfc5060e7 100644
--- a/src/infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php
+++ b/src/infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php
@@ -1,365 +1,365 @@
 <?php
 
 final class PhutilRemarkupCodeBlockRule extends PhutilRemarkupBlockRule {
 
   public function getMatchingLineCount(array $lines, $cursor) {
     $num_lines = 0;
     $match_ticks = null;
     if (preg_match('/^(\s{2,}).+/', $lines[$cursor])) {
       $match_ticks = false;
     } else if (preg_match('/^\s*(```)/', $lines[$cursor])) {
       $match_ticks = true;
     } else {
       return $num_lines;
     }
 
     $num_lines++;
 
     if ($match_ticks &&
         preg_match('/^\s*(```)(.*)(```)\s*$/', $lines[$cursor])) {
       return $num_lines;
     }
 
     $cursor++;
 
     while (isset($lines[$cursor])) {
       if ($match_ticks) {
         if (preg_match('/```\s*$/', $lines[$cursor])) {
           $num_lines++;
           break;
         }
         $num_lines++;
       } else {
         if (strlen(trim($lines[$cursor]))) {
           if (!preg_match('/^\s{2,}/', $lines[$cursor])) {
             break;
           }
         }
         $num_lines++;
       }
       $cursor++;
     }
 
     return $num_lines;
   }
 
   public function markupText($text, $children) {
     // Header/footer eventually useful to be nice with "flavored markdown".
     // When it starts with ```stuff    the header is 'stuff' (->language)
     // When it ends with      stuff``` the footer is 'stuff' (->garbage)
     $header_line = null;
     $footer_line = null;
 
     $matches = null;
     if (preg_match('/^\s*```(.*)/', $text, $matches)) {
       if (isset($matches[1])) {
         $header_line = $matches[1];
       }
 
       // If this is a ```-style block, trim off the backticks and any leading
       // blank line.
       $text = preg_replace('/^\s*```(\s*\n)?/', '', $text);
       $text = preg_replace('/```\s*$/', '', $text);
     }
 
     $lines = explode("\n", $text);
 
     // If we have a flavored header, it has sense to look for the footer.
     if ($header_line !== null && $lines) {
       $footer_line = $lines[last_key($lines)];
     }
 
     // Strip final empty lines
     while ($lines && !strlen(last($lines))) {
       unset($lines[last_key($lines)]);
     }
 
     $options = array(
       'counterexample'  => false,
       'lang'            => null,
       'name'            => null,
       'lines'           => null,
     );
 
     $parser = new PhutilSimpleOptions();
     $custom = $parser->parse(head($lines));
     $valid_options = null;
     if ($custom) {
       $valid_options = true;
       foreach ($custom as $key => $value) {
         if (!array_key_exists($key, $options)) {
           $valid_options = false;
           break;
         }
       }
       if ($valid_options) {
         array_shift($lines);
         $options = $custom + $options;
       }
     }
 
     // Parse flavored markdown strictly to don't eat legitimate Remarkup.
     // Proceed only if we tried to parse options and we failed
     // (no options also mean no language).
     // For example this is not a valid option: ```php
     // Proceed only if the footer exists and it is not: blabla```
     // Accept only 2 lines or more. First line: header; then content.
     if (
       $valid_options === false &&
       $header_line !== null &&
       $footer_line === '' &&
       count($lines) > 1
     ) {
       if (self::isKnownLanguageCode($header_line)) {
         array_shift($lines);
         $options['lang'] = $header_line;
       }
     }
 
     // Normalize the text back to a 0-level indent.
     $min_indent = 80;
     foreach ($lines as $line) {
       for ($ii = 0; $ii < strlen($line); $ii++) {
         if ($line[$ii] != ' ') {
           $min_indent = min($ii, $min_indent);
           break;
         }
       }
     }
 
     $text = implode("\n", $lines);
     if ($min_indent) {
       $indent_string = str_repeat(' ', $min_indent);
       $text = preg_replace('/^'.$indent_string.'/m', '', $text);
     }
 
     if ($this->getEngine()->isTextMode()) {
       $out = array();
 
       $header = array();
       if ($options['counterexample']) {
         $header[] = 'counterexample';
       }
       if ($options['name'] != '') {
         $header[] = 'name='.$options['name'];
       }
       if ($header) {
         $out[] = implode(', ', $header);
       }
 
       $text = preg_replace('/^/m', '  ', $text);
       $out[] = $text;
 
       return implode("\n", $out);
     }
 
     // The name is usually a sufficient source of information for file ext.
     if (empty($options['lang']) && isset($options['name'])) {
       $options['lang'] = $this->guessFilenameExtension($options['name']);
     }
 
     if (empty($options['lang'])) {
       // If the user hasn't specified "lang=..." explicitly, try to guess the
       // language. If we fail, fall back to configured defaults.
       $lang = PhutilLanguageGuesser::guessLanguage($text);
       if (!$lang) {
         $lang = nonempty(
           $this->getEngine()->getConfig('phutil.codeblock.language-default'),
           'text');
       }
       $options['lang'] = $lang;
     }
 
     $code_body = $this->highlightSource($text, $options);
 
     $name_header = null;
     $block_style = null;
     if ($this->getEngine()->isHTMLMailMode()) {
       $map = $this->getEngine()->getConfig('phutil.codeblock.style-map');
 
       if ($map) {
         $raw_body = id(new PhutilPygmentizeParser())
           ->setMap($map)
           ->parse((string)$code_body);
         $code_body = phutil_safe_html($raw_body);
       }
 
       $style_rules = array(
         'padding: 6px 12px;',
         'font-size: 13px;',
         'font-weight: bold;',
         'display: inline-block;',
         'border-top-left-radius: 3px;',
         'border-top-right-radius: 3px;',
         'color: rgba(0,0,0,.75);',
       );
 
       if ($options['counterexample']) {
         $style_rules[] = 'background: #f7e6e6';
       } else {
         $style_rules[] = 'background: rgba(71, 87, 120, 0.08);';
       }
 
       $header_attributes = array(
         'style' => implode(' ', $style_rules),
       );
 
       $block_style = 'margin: 12px 0;';
     } else {
       $header_attributes = array(
         'class' => 'remarkup-code-header',
       );
     }
 
     if ($options['name']) {
       $name_header = phutil_tag(
         'div',
         $header_attributes,
         $options['name']);
     }
 
     $class = 'remarkup-code-block';
     if ($options['counterexample']) {
       $class = 'remarkup-code-block code-block-counterexample';
     }
 
     $attributes = array(
       'class' => $class,
       'style' => $block_style,
       'data-code-lang' => $options['lang'],
       'data-sigil' => 'remarkup-code-block',
     );
 
     return phutil_tag(
       'div',
       $attributes,
       array($name_header, $code_body));
   }
 
   private function highlightSource($text, array $options) {
     if ($options['counterexample']) {
       $aux_class = ' remarkup-counterexample';
     } else {
       $aux_class = null;
     }
 
     $aux_style = null;
 
     if ($this->getEngine()->isHTMLMailMode()) {
       $aux_style = array(
         'font: 11px/15px "Menlo", "Consolas", "Monaco", monospace;',
         'padding: 12px;',
         'margin: 0;',
       );
 
       if ($options['counterexample']) {
         $aux_style[] = 'background: #f7e6e6;';
       } else {
         $aux_style[] = 'background: rgba(71, 87, 120, 0.08);';
       }
 
       $aux_style = implode(' ', $aux_style);
     }
 
     if ($options['lines']) {
       // Put a minimum size on this because the scrollbar is otherwise
       // unusable.
       $height = max(6, (int)$options['lines']);
       $aux_style = $aux_style
         .' '
         .'max-height: '
         .(2 * $height)
         .'em; overflow: auto;';
     }
 
     $engine = $this->getEngine()->getConfig('syntax-highlighter.engine');
     if (!$engine) {
       $engine = 'PhutilDefaultSyntaxHighlighterEngine';
     }
     $engine = newv($engine, array());
     $engine->setConfig(
       'pygments.enabled',
       $this->getEngine()->getConfig('pygments.enabled'));
     return phutil_tag(
       'pre',
       array(
         'class' => 'remarkup-code'.$aux_class,
         'style' => $aux_style,
       ),
       PhutilSafeHTML::applyFunction(
         'rtrim',
         $engine->highlightSource($options['lang'], $text)));
   }
 
   /**
    * Check if a language code can be used in a generic flavored markdown.
    * @param  string $lang Language code
    * @return bool
    */
   private static function isKnownLanguageCode($lang) {
     $languages = self::knownLanguageCodes();
     return isset($languages[$lang]);
   }
 
   /**
    * Get the available languages for a generic flavored markdown.
    * @return array Languages as array keys. Ignore the value.
    */
   private static function knownLanguageCodes() {
     // This is a friendly subset from https://pygments.org/languages/
     static $map = array(
       'arduino' => 1,
       'assembly' => 1,
       'awk' => 1,
       'bash' => 1,
       'bat' => 1,
       'c' => 1,
       'cmake' => 1,
       'cobol' => 1,
       'cpp' => 1,
       'css' => 1,
       'csharp' => 1,
       'dart' => 1,
       'delphi' => 1,
       'fortran' => 1,
       'go' => 1,
       'groovy' => 1,
       'haskell' => 1,
       'java' => 1,
       'javascript' => 1,
       'kotlin' => 1,
       'lisp' => 1,
       'lua' => 1,
       'matlab' => 1,
       'make' => 1,
       'perl' => 1,
       'php' => 1,
       'powershell' => 1,
       'python' => 1,
       'r' => 1,
       'ruby' => 1,
       'rust' => 1,
       'scala' => 1,
       'sh' => 1,
       'sql' => 1,
       'typescript' => 1,
       'vba' => 1,
     );
     return $map;
   }
 
   /**
    * Get the extension from a filename.
-   * @param  string "/path/to/something.name"
+   * @param  string $name "/path/to/something.name"
    * @return null|string ".name"
    */
   private function guessFilenameExtension($name) {
     $name = basename($name);
     $pos = strrpos($name, '.');
     if ($pos !== false) {
       return substr($name, $pos + 1);
     }
     return null;
   }
 
 }
diff --git a/src/infrastructure/markup/markuprule/PhutilRemarkupRule.php b/src/infrastructure/markup/markuprule/PhutilRemarkupRule.php
index a5ddaa4ab6..6513d0f66c 100644
--- a/src/infrastructure/markup/markuprule/PhutilRemarkupRule.php
+++ b/src/infrastructure/markup/markuprule/PhutilRemarkupRule.php
@@ -1,131 +1,132 @@
 <?php
 
 abstract class PhutilRemarkupRule extends Phobject {
 
   private $engine;
   private $replaceCallback;
 
   public function setEngine(PhutilRemarkupEngine $engine) {
     $this->engine = $engine;
     return $this;
   }
 
   public function getEngine() {
     return $this->engine;
   }
 
   public function getPriority() {
     return 500.0;
   }
 
   /**
    * Check input whether to apply RemarkupRule. If true, apply formatting.
-   * @param  string|PhutilSafeHTML String to check and potentially format.
+   * @param  string|PhutilSafeHTML $text String to check and potentially
+   *   format.
    * @return string|PhutilSafeHTML Unchanged input if no match, or input after
-   * matching the formatting rule and applying the formatting.
+   *   matching the formatting rule and applying the formatting.
    */
   abstract public function apply($text);
 
   public function getPostprocessKey() {
     return spl_object_hash($this);
   }
 
   public function didMarkupText() {
     return;
   }
 
   protected function replaceHTML($pattern, $callback, $text) {
     $this->replaceCallback = $callback;
     return phutil_safe_html(preg_replace_callback(
       $pattern,
       array($this, 'replaceHTMLCallback'),
       phutil_escape_html($text)));
   }
 
   private function replaceHTMLCallback(array $match) {
     return phutil_escape_html(call_user_func(
       $this->replaceCallback,
       array_map('phutil_safe_html', $match)));
   }
 
 
   /**
    * Safely generate a tag.
    *
    * In Remarkup contexts, it's not safe to use arbitrary text in tag
    * attributes: even though it will be escaped, it may contain replacement
    * tokens which are then replaced with markup.
    *
    * This method acts as @{function:phutil_tag}, but checks attributes before
    * using them.
    *
-   * @param   string              Tag name.
-   * @param   dict<string, wild>  Tag attributes.
-   * @param   wild                Tag content.
+   * @param   string              $name Tag name.
+   * @param   dict<string, wild>  $attrs Tag attributes.
+   * @param   wild?               $content Tag content.
    * @return  PhutilSafeHTML      Tag object.
    */
   protected function newTag($name, array $attrs, $content = null) {
     foreach ($attrs as $key => $attr) {
       if ($attr !== null) {
         $attrs[$key] = $this->assertFlatText($attr);
       }
     }
 
     return phutil_tag($name, $attrs, $content);
   }
 
   /**
    * Assert that a text token is flat (it contains no replacement tokens).
    *
    * Because tokens can be replaced with markup, it is dangerous to use
    * arbitrary input text in tag attributes. Normally, rule precedence should
    * prevent this. Asserting that text is flat before using it as an attribute
    * provides an extra layer of security.
    *
    * Normally, you can call @{method:newTag} rather than calling this method
    * directly. @{method:newTag} will check attributes for you.
    *
-   * @param   wild    Ostensibly flat text.
+   * @param   wild    $text Ostensibly flat text.
    * @return  string  Flat text.
    */
   protected function assertFlatText($text) {
     $text = (string)hsprintf('%s', phutil_safe_html($text));
     $rich = (strpos($text, PhutilRemarkupBlockStorage::MAGIC_BYTE) !== false);
     if ($rich) {
       throw new Exception(
         pht(
           'Remarkup rule precedence is dangerous: rendering text with tokens '.
           'as flat text!'));
     }
 
     return $text;
   }
 
   /**
    * Check whether text is flat (contains no replacement tokens) or not.
    *
-   * @param   wild  Ostensibly flat text.
+   * @param   wild  $text Ostensibly flat text.
    * @return  bool  True if the text is flat.
    */
   protected function isFlatText($text) {
     $text = (string)hsprintf('%s', phutil_safe_html($text));
     return (strpos($text, PhutilRemarkupBlockStorage::MAGIC_BYTE) === false);
   }
 
   /**
    * Get the CSS class="" attribute for a Remarkup link.
    * It's just "remarkup-link" for all cases, plus the possibility for
    * designers to style external links differently.
    * @param  boolean $is_internal Whenever the link was internal or not.
    * @return string
    */
   protected function getRemarkupLinkClass($is_internal) {
     // Allow developers to style esternal links differently
     $classes = array('remarkup-link');
     if (!$is_internal) {
       $classes[] = 'remarkup-link-ext';
     }
     return implode(' ', $classes);
   }
 
 }
diff --git a/src/infrastructure/markup/render.php b/src/infrastructure/markup/render.php
index 84c3616fe8..eaa4e067b2 100644
--- a/src/infrastructure/markup/render.php
+++ b/src/infrastructure/markup/render.php
@@ -1,187 +1,187 @@
 <?php
 
 /**
  * Render an HTML tag in a way that treats user content as unsafe by default.
  *
  * Tag rendering has some special logic which implements security features:
  *
  *   - When rendering `<a>` tags, if the `rel` attribute is not specified, it
  *     is interpreted as `rel="noreferrer"`.
  *   - When rendering `<a>` tags, the `href` attribute may not begin with
  *     `javascript:`.
  *
  * These special cases can not be disabled.
  *
  * IMPORTANT: The `$tag` attribute and the keys of the `$attributes` array are
  * trusted blindly, and not escaped. You should not pass user data in these
  * parameters.
  *
- * @param string The name of the tag, like `a` or `div`.
- * @param map<string, string> A map of tag attributes.
- * @param wild Content to put in the tag.
+ * @param string $tag The name of the tag, like `a` or `div`.
+ * @param map<string, string>? $attributes A map of tag attributes.
+ * @param wild? $content Content to put in the tag.
  * @return PhutilSafeHTML Tag object.
  */
 function phutil_tag($tag, array $attributes = array(), $content = null) {
   // If the `href` attribute is present, make sure it is not a "javascript:"
   // URI. We never permit these.
   if (!empty($attributes['href'])) {
     // This might be a URI object, so cast it to a string.
     $href = (string)$attributes['href'];
 
     if (isset($href[0])) {
       // Block 'javascript:' hrefs at the tag level: no well-designed
       // application should ever use them, and they are a potent attack vector.
 
       // This function is deep in the core and performance sensitive, so we're
       // doing a cheap version of this test first to avoid calling preg_match()
       // on URIs which begin with '/' or `#`. These cover essentially all URIs
       // in Phabricator.
       if (($href[0] !== '/') && ($href[0] !== '#')) {
         // Chrome 33 and IE 11 both interpret "javascript\n:" as a Javascript
         // URI, and all browsers interpret "  javascript:" as a Javascript URI,
         // so be aggressive about looking for "javascript:" in the initial
         // section of the string.
 
         $normalized_href = preg_replace('([^a-z0-9/:]+)i', '', $href);
         if (preg_match('/^javascript:/i', $normalized_href)) {
           throw new Exception(
             pht(
               "Attempting to render a tag with an '%s' attribute that begins ".
               "with '%s'. This is either a serious security concern or a ".
               "serious architecture concern. Seek urgent remedy.",
               'href',
               'javascript:'));
         }
       }
     }
   }
 
   // For tags which can't self-close, treat null as the empty string -- for
   // example, always render `<div></div>`, never `<div />`.
   static $self_closing_tags = array(
     'area'    => true,
     'base'    => true,
     'br'      => true,
     'col'     => true,
     'command' => true,
     'embed'   => true,
     'frame'   => true,
     'hr'      => true,
     'img'     => true,
     'input'   => true,
     'keygen'  => true,
     'link'    => true,
     'meta'    => true,
     'param'   => true,
     'source'  => true,
     'track'   => true,
     'wbr'     => true,
   );
 
   $attr_string = '';
   foreach ($attributes as $k => $v) {
     if ($v === null) {
       continue;
     }
     $v = phutil_escape_html($v);
     $attr_string .= ' '.$k.'="'.$v.'"';
   }
 
   if ($content === null) {
     if (isset($self_closing_tags[$tag])) {
       return new PhutilSafeHTML('<'.$tag.$attr_string.' />');
     } else {
       $content = '';
     }
   } else {
     $content = phutil_escape_html($content);
   }
 
   return new PhutilSafeHTML('<'.$tag.$attr_string.'>'.$content.'</'.$tag.'>');
 }
 
 function phutil_tag_div($class, $content = null) {
   return phutil_tag('div', array('class' => $class), $content);
 }
 
 function phutil_escape_html($string) {
   if ($string === null) {
     return '';
   }
 
   if ($string instanceof PhutilSafeHTML) {
     return $string;
   } else if ($string instanceof PhutilSafeHTMLProducerInterface) {
     $result = $string->producePhutilSafeHTML();
     if ($result instanceof PhutilSafeHTML) {
       return phutil_escape_html($result);
     } else if (is_array($result)) {
       return phutil_escape_html($result);
     } else if ($result instanceof PhutilSafeHTMLProducerInterface) {
       return phutil_escape_html($result);
     } else {
       try {
         assert_stringlike($result);
         return phutil_escape_html((string)$result);
       } catch (Exception $ex) {
         throw new Exception(
           pht(
             "Object (of class '%s') implements %s but did not return anything ".
             "renderable from %s.",
             get_class($string),
             'PhutilSafeHTMLProducerInterface',
             'producePhutilSafeHTML()'));
       }
     }
   } else if (is_array($string)) {
     $result = '';
     foreach ($string as $item) {
       $result .= phutil_escape_html($item);
     }
     return $result;
   }
 
   return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
 }
 
 function phutil_escape_html_newlines($string) {
   return PhutilSafeHTML::applyFunction('nl2br', $string);
 }
 
 /**
  * Mark string as safe for use in HTML.
  */
 function phutil_safe_html($string) {
   if ($string == '') {
     return $string;
   } else if ($string instanceof PhutilSafeHTML) {
     return $string;
   } else {
     return new PhutilSafeHTML($string);
   }
 }
 
 /**
  * HTML safe version of `implode()`.
  */
 function phutil_implode_html($glue, array $pieces) {
   $glue = phutil_escape_html($glue);
 
   foreach ($pieces as $k => $piece) {
     $pieces[$k] = phutil_escape_html($piece);
   }
 
   return phutil_safe_html(implode($glue, $pieces));
 }
 
 /**
  * Format a HTML code. This function behaves like `sprintf()`, except that all
  * the normal conversions (like %s) will be properly escaped.
  */
 function hsprintf($html /* , ... */) {
   $args = func_get_args();
   array_shift($args);
   return new PhutilSafeHTML(
     vsprintf($html, array_map('phutil_escape_html', $args)));
 }
 
diff --git a/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php b/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php
index b0399527b0..c0311ea236 100644
--- a/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php
+++ b/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php
@@ -1,432 +1,432 @@
 <?php
 
 abstract class PhabricatorObjectRemarkupRule extends PhutilRemarkupRule {
 
   private $referencePattern;
   private $embedPattern;
 
   const KEY_RULE_OBJECT = 'rule.object';
   const KEY_MENTIONED_OBJECTS = 'rule.object.mentioned';
 
   abstract protected function getObjectNamePrefix();
   abstract protected function loadObjects(array $ids);
 
   public function getPriority() {
     return 450.0;
   }
 
   protected function getObjectNamePrefixBeginsWithWordCharacter() {
     $prefix = $this->getObjectNamePrefix();
     return preg_match('/^\w/', $prefix);
   }
 
   protected function getObjectIDPattern() {
     return '[1-9]\d*';
   }
 
   protected function shouldMarkupObject(array $params) {
     return true;
   }
 
   protected function getObjectNameText(
     $object,
     PhabricatorObjectHandle $handle,
     $id) {
     return $this->getObjectNamePrefix().$id;
   }
 
   protected function loadHandles(array $objects) {
     $phids = mpull($objects, 'getPHID');
 
     $viewer = $this->getEngine()->getConfig('viewer');
     $handles = $viewer->loadHandles($phids);
     $handles = iterator_to_array($handles);
 
     $result = array();
     foreach ($objects as $id => $object) {
       $result[$id] = $handles[$object->getPHID()];
     }
     return $result;
   }
 
   protected function getObjectHref(
     $object,
     PhabricatorObjectHandle $handle,
     $id) {
 
     $uri = $handle->getURI();
 
     if ($this->getEngine()->getConfig('uri.full')) {
       $uri = PhabricatorEnv::getURI($uri);
     }
 
     return $uri;
   }
 
   protected function renderObjectRefForAnyMedia(
     $object,
     PhabricatorObjectHandle $handle,
     $anchor,
     $id) {
 
     $href = $this->getObjectHref($object, $handle, $id);
     $text = $this->getObjectNameText($object, $handle, $id);
 
     if ($anchor) {
       $href = $href.'#'.$anchor;
       $text = $text.'#'.$anchor;
     }
 
     if ($this->getEngine()->isTextMode()) {
       return $text.' <'.PhabricatorEnv::getProductionURI($href).'>';
     } else if ($this->getEngine()->isHTMLMailMode()) {
       $href = PhabricatorEnv::getProductionURI($href);
       return $this->renderObjectTagForMail($text, $href, $handle);
     }
 
     return $this->renderObjectRef($object, $handle, $anchor, $id);
 
   }
 
   protected function renderObjectRef(
     $object,
     PhabricatorObjectHandle $handle,
     $anchor,
     $id) {
 
     $href = $this->getObjectHref($object, $handle, $id);
     $text = $this->getObjectNameText($object, $handle, $id);
     $status_closed = PhabricatorObjectHandle::STATUS_CLOSED;
 
     if ($anchor) {
       $href = $href.'#'.$anchor;
       $text = $text.'#'.$anchor;
     }
 
     $attr = array(
       'phid'    => $handle->getPHID(),
       'closed'  => ($handle->getStatus() == $status_closed),
     );
 
     return $this->renderHovertag($text, $href, $attr);
   }
 
   protected function renderObjectEmbedForAnyMedia(
     $object,
     PhabricatorObjectHandle $handle,
     $options) {
 
     $name = $handle->getFullName();
     $href = $handle->getURI();
 
     if ($this->getEngine()->isTextMode()) {
       return $name.' <'.PhabricatorEnv::getProductionURI($href).'>';
     } else if ($this->getEngine()->isHTMLMailMode()) {
       $href = PhabricatorEnv::getProductionURI($href);
       return $this->renderObjectTagForMail($name, $href, $handle);
     }
 
     // See T13678. If we're already rendering embedded content, render a
     // default reference instead to avoid cycles.
     if (PhabricatorMarkupEngine::isRenderingEmbeddedContent()) {
       return $this->renderDefaultObjectEmbed($object, $handle);
     }
 
     return $this->renderObjectEmbed($object, $handle, $options);
   }
 
   protected function renderObjectEmbed(
     $object,
     PhabricatorObjectHandle $handle,
     $options) {
     return $this->renderDefaultObjectEmbed($object, $handle);
   }
 
   final protected function renderDefaultObjectEmbed(
     $object,
     PhabricatorObjectHandle $handle) {
 
     $name = $handle->getFullName();
     $href = $handle->getURI();
     $status_closed = PhabricatorObjectHandle::STATUS_CLOSED;
     $attr = array(
       'phid' => $handle->getPHID(),
       'closed'  => ($handle->getStatus() == $status_closed),
     );
 
     return $this->renderHovertag($name, $href, $attr);
   }
 
   protected function renderObjectTagForMail(
     $text,
     $href,
     PhabricatorObjectHandle $handle) {
 
     $status_closed = PhabricatorObjectHandle::STATUS_CLOSED;
     $strikethrough = $handle->getStatus() == $status_closed ?
       'text-decoration: line-through;' :
       'text-decoration: none;';
 
     return phutil_tag(
       'a',
       array(
         'href' => $href,
         'style' => 'background-color: #e7e7e7;
           border-color: #e7e7e7;
           border-radius: 3px;
           padding: 0 4px;
           font-weight: bold;
           color: black;'
           .$strikethrough,
       ),
       $text);
   }
 
   protected function renderHovertag($name, $href, array $attr = array()) {
     return id(new PHUITagView())
       ->setName($name)
       ->setHref($href)
       ->setType(PHUITagView::TYPE_OBJECT)
       ->setPHID(idx($attr, 'phid'))
       ->setClosed(idx($attr, 'closed'))
       ->render();
   }
 
   public function apply($text) {
     $text = preg_replace_callback(
       $this->getObjectEmbedPattern(),
       array($this, 'markupObjectEmbed'),
       $text);
 
     $text = preg_replace_callback(
       $this->getObjectReferencePattern(),
       array($this, 'markupObjectReference'),
       $text);
 
     return $text;
   }
 
   private function getObjectEmbedPattern() {
     if ($this->embedPattern === null) {
       $prefix = $this->getObjectNamePrefix();
       $prefix = preg_quote($prefix);
       $id = $this->getObjectIDPattern();
 
       $this->embedPattern =
         '(\B{'.$prefix.'('.$id.')([,\s](?:[^}\\\\]|\\\\.)*)?}\B)u';
     }
 
     return $this->embedPattern;
   }
 
   private function getObjectReferencePattern() {
     if ($this->referencePattern === null) {
       $prefix = $this->getObjectNamePrefix();
       $prefix = preg_quote($prefix);
 
       $id = $this->getObjectIDPattern();
 
       // If the prefix starts with a word character (like "D"), we want to
       // require a word boundary so that we don't match "XD1" as "D1". If the
       // prefix does not start with a word character, we want to require no word
       // boundary for the same reasons. Test if the prefix starts with a word
       // character.
       if ($this->getObjectNamePrefixBeginsWithWordCharacter()) {
         $boundary = '\\b';
       } else {
         $boundary = '\\B';
       }
 
       // The "(?<![#@-])" prevents us from linking "#abcdef" or similar, and
       // "ABC-T1" (see T5714), and from matching "@T1" as a task (it is a user)
       // (see T9479).
 
       // The "\b" allows us to link "(abcdef)" or similar without linking things
       // in the middle of words.
 
       $this->referencePattern =
         '((?<![#@-])'.$boundary.$prefix.'('.$id.')(?:#([-\w\d]+))?(?!\w))u';
     }
 
     return $this->referencePattern;
   }
 
 
   /**
    * Extract matched object references from a block of text.
    *
    * This is intended to make it easy to write unit tests for object remarkup
    * rules. Production code is not normally expected to call this method.
    *
-   * @param   string  Text to match rules against.
+   * @param   string  $text Text to match rules against.
    * @return  wild    Matches, suitable for writing unit tests against.
    */
   public function extractReferences($text) {
     $embed_matches = null;
     preg_match_all(
       $this->getObjectEmbedPattern(),
       $text,
       $embed_matches,
       PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
 
     $ref_matches = null;
     preg_match_all(
       $this->getObjectReferencePattern(),
       $text,
       $ref_matches,
       PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
 
     $results = array();
     $sets = array(
       'embed' => $embed_matches,
       'ref' => $ref_matches,
     );
     foreach ($sets as $type => $matches) {
       $formatted = array();
       foreach ($matches as $match) {
         $format = array(
           'offset' => $match[1][1],
           'id' => $match[1][0],
         );
         if (isset($match[2][0])) {
           $format['tail'] = $match[2][0];
         }
         $formatted[] = $format;
       }
       $results[$type] = $formatted;
     }
 
     return $results;
   }
 
   public function markupObjectEmbed(array $matches) {
     if (!$this->isFlatText($matches[0])) {
       return $matches[0];
     }
 
     // If we're rendering a table of contents, just render the raw input.
     // This could perhaps be handled more gracefully but it seems unusual to
     // put something like "{P123}" in a header and it's not obvious what users
     // expect? See T8845.
     $engine = $this->getEngine();
     if ($engine->getState('toc')) {
       return $matches[0];
     }
 
     return $this->markupObject(array(
       'type' => 'embed',
       'id' => $matches[1],
       'options' => idx($matches, 2),
       'original' => $matches[0],
       'quote.depth' => $engine->getQuoteDepth(),
     ));
   }
 
   public function markupObjectReference(array $matches) {
     if (!$this->isFlatText($matches[0])) {
       return $matches[0];
     }
 
     // If we're rendering a table of contents, just render the monogram.
     $engine = $this->getEngine();
     if ($engine->getState('toc')) {
       return $matches[0];
     }
 
     return $this->markupObject(array(
       'type' => 'ref',
       'id' => $matches[1],
       'anchor' => idx($matches, 2),
       'original' => $matches[0],
       'quote.depth' => $engine->getQuoteDepth(),
     ));
   }
 
   private function markupObject(array $params) {
     if (!$this->shouldMarkupObject($params)) {
       return $params['original'];
     }
 
     $regex = trim(
       PhabricatorEnv::getEnvConfig('remarkup.ignored-object-names'));
     if ($regex && preg_match($regex, $params['original'])) {
       return $params['original'];
     }
 
     $engine = $this->getEngine();
     $token = $engine->storeText('x');
 
     $metadata_key = self::KEY_RULE_OBJECT.'.'.$this->getObjectNamePrefix();
     $metadata = $engine->getTextMetadata($metadata_key, array());
 
     $metadata[] = array(
       'token'   => $token,
     ) + $params;
 
     $engine->setTextMetadata($metadata_key, $metadata);
 
     return $token;
   }
 
   public function didMarkupText() {
     $engine = $this->getEngine();
     $metadata_key = self::KEY_RULE_OBJECT.'.'.$this->getObjectNamePrefix();
     $metadata = $engine->getTextMetadata($metadata_key, array());
 
     if (!$metadata) {
       return;
     }
 
 
     $ids = ipull($metadata, 'id');
     $objects = $this->loadObjects($ids);
 
     // For objects that are invalid or which the user can't see, just render
     // the original text.
 
     // TODO: We should probably distinguish between these cases and render a
     // "you can't see this" state for nonvisible objects.
 
     foreach ($metadata as $key => $spec) {
       if (empty($objects[$spec['id']])) {
         $engine->overwriteStoredText(
           $spec['token'],
           $spec['original']);
         unset($metadata[$key]);
       }
     }
 
     $phids = $engine->getTextMetadata(self::KEY_MENTIONED_OBJECTS, array());
     foreach ($objects as $object) {
       $phids[$object->getPHID()] = $object->getPHID();
     }
     $engine->setTextMetadata(self::KEY_MENTIONED_OBJECTS, $phids);
 
     $handles = $this->loadHandles($objects);
     foreach ($metadata as $key => $spec) {
       $handle = $handles[$spec['id']];
       $object = $objects[$spec['id']];
       switch ($spec['type']) {
         case 'ref':
 
           $view = $this->renderObjectRefForAnyMedia(
             $object,
             $handle,
             $spec['anchor'],
             $spec['id']);
           break;
         case 'embed':
           $spec['options'] = $this->assertFlatText($spec['options']);
           $view = $this->renderObjectEmbedForAnyMedia(
             $object,
             $handle,
             $spec['options']);
           break;
       }
       $engine->overwriteStoredText($spec['token'], $view);
     }
 
     $engine->setTextMetadata($metadata_key, array());
   }
 
 }
diff --git a/src/infrastructure/parser/PhutilURIHelper.php b/src/infrastructure/parser/PhutilURIHelper.php
index 4341585e39..a43fafe576 100644
--- a/src/infrastructure/parser/PhutilURIHelper.php
+++ b/src/infrastructure/parser/PhutilURIHelper.php
@@ -1,78 +1,78 @@
 <?php
 
 /**
  * A simple wrapper for PhutilURI, to be aware of the
  * relative/absolute context, and other minor things.
  */
 final class PhutilURIHelper extends Phobject {
 
   /**
    * String version of your original URI.
    * @var string
    */
   private $uriStr;
 
   /**
    * Structured version of your URI.
    * @var PhutilURI
    */
   private $phutilUri;
 
   /**
-   * @param string|PhutilURI
+   * @param string|PhutilURI $uri
    */
   public function __construct($uri) {
 
     // Keep the original string for basic checks.
     $this->uriStr = phutil_string_cast($uri);
 
     // A PhutilURI may be useful. If available, import that as-is.
     // Note that the constructor PhutilURI(string) is a bit expensive.
     if ($uri instanceof PhutilURI) {
       $this->phutilUri = $uri;
     }
   }
 
   /**
    * Check if the URI points to Phorge itself.
    * @return bool
    */
   public function isSelf() {
     // The backend prefers a PhutilURI object, if available.
     $uri = $this->phutilUri ? $this->phutilUri : $this->uriStr;
     return PhabricatorEnv::isSelfURI($uri);
   }
 
   /**
    * Check whenever an URI is just a simple fragment without path and protocol.
    * @return bool
    */
   public function isAnchor() {
     return $this->isStartingWithChar('#');
   }
 
   /**
    * Check whenever an URI starts with a slash (no protocol, etc.)
    * @return bool
    */
   public function isStartingWithSlash() {
     return $this->isStartingWithChar('/');
   }
 
   /**
    * A sane default.
    */
   public function __toString() {
     return $this->uriStr;
   }
 
   /**
    * Check whenever the URI starts with the provided character.
    * @param string $char String that MUST have length of 1.
    * @return boolean
    */
   private function isStartingWithChar($char) {
     return strncmp($this->uriStr, $char, 1) === 0;
   }
 
 }
diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
index cdd941a5f4..7d57df7c35 100644
--- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
+++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
@@ -1,3286 +1,3290 @@
 <?php
 
 /**
  * A query class which uses cursor-based paging. This paging is much more
  * performant than offset-based paging in the presence of policy filtering.
  *
  * @task cursors Query Cursors
  * @task clauses Building Query Clauses
  * @task appsearch Integration with ApplicationSearch
  * @task customfield Integration with CustomField
  * @task paging Paging
  * @task order Result Ordering
  * @task edgelogic Working with Edge Logic
  * @task spaces Working with Spaces
  */
 abstract class PhabricatorCursorPagedPolicyAwareQuery
   extends PhabricatorPolicyAwareQuery {
 
   private $externalCursorString;
   private $internalCursorObject;
   private $isQueryOrderReversed = false;
   private $rawCursorRow;
 
   private $applicationSearchConstraints = array();
   private $internalPaging;
   private $orderVector;
   private $groupVector;
   private $builtinOrder;
   private $edgeLogicConstraints = array();
   private $edgeLogicConstraintsAreValid = false;
   private $spacePHIDs;
   private $spaceIsArchived;
   private $ngrams = array();
   private $ferretEngine;
   private $ferretTokens = array();
   private $ferretTables = array();
   private $ferretQuery;
   private $ferretMetadata = array();
   private $ngramEngine;
 
   const FULLTEXT_RANK = '_ft_rank';
   const FULLTEXT_MODIFIED = '_ft_epochModified';
   const FULLTEXT_CREATED = '_ft_epochCreated';
 
 /* -(  Cursors  )------------------------------------------------------------ */
 
   protected function newExternalCursorStringForResult($object) {
     if (!($object instanceof LiskDAO)) {
       throw new Exception(
         pht(
           'Expected to be passed a result object of class "LiskDAO" in '.
           '"newExternalCursorStringForResult()", actually passed "%s". '.
           'Return storage objects from "loadPage()" or override '.
           '"newExternalCursorStringForResult()".',
           phutil_describe_type($object)));
     }
 
     return (string)$object->getID();
   }
 
   protected function newInternalCursorFromExternalCursor($cursor) {
     $viewer = $this->getViewer();
 
     $query = newv(get_class($this), array());
 
     $query
       ->setParentQuery($this)
       ->setViewer($viewer);
 
     // We're copying our order vector to the subquery so that the subquery
     // knows it should generate any supplemental information required by the
     // ordering.
 
     // For example, Phriction documents may be ordered by title, but the title
     // isn't a column in the "document" table: the query must JOIN the
     // "content" table to perform the ordering. Passing the ordering to the
     // subquery tells it that we need it to do that JOIN and attach relevant
     // paging information to the internal cursor object.
 
     // We only expect to load a single result, so the actual result order does
     // not matter. We only want the internal cursor for that result to look
     // like a cursor this parent query would generate.
     $query->setOrderVector($this->getOrderVector());
 
     $this->applyExternalCursorConstraintsToQuery($query, $cursor);
 
     // If we have a Ferret fulltext query, copy it to the subquery so that we
     // generate ranking columns appropriately, and compute the correct object
     // ranking score for the current query.
     if ($this->ferretEngine) {
       $query->withFerretConstraint($this->ferretEngine, $this->ferretTokens);
     }
 
     // We're executing the subquery normally to make sure the viewer can
     // actually see the object, and that it's a completely valid object which
     // passes all filtering and policy checks. You aren't allowed to use an
     // object you can't see as a cursor, since this can leak information.
     $result = $query->executeOne();
     if (!$result) {
       $this->throwCursorException(
         pht(
           'Cursor "%s" does not identify a valid object in query "%s".',
           $cursor,
           get_class($this)));
     }
 
     // Now that we made sure the viewer can actually see the object the
     // external cursor identifies, return the internal cursor the query
     // generated as a side effect while loading the object.
     return $query->getInternalCursorObject();
   }
 
   final protected function throwCursorException($message) {
     throw new PhabricatorInvalidQueryCursorException($message);
   }
 
   protected function applyExternalCursorConstraintsToQuery(
     PhabricatorCursorPagedPolicyAwareQuery $subquery,
     $cursor) {
     $subquery->withIDs(array($cursor));
   }
 
   protected function newPagingMapFromCursorObject(
     PhabricatorQueryCursor $cursor,
     array $keys) {
 
     $object = $cursor->getObject();
 
     return $this->newPagingMapFromPartialObject($object);
   }
 
   protected function newPagingMapFromPartialObject($object) {
     return array(
       'id' => (int)$object->getID(),
     );
   }
 
   private function getExternalCursorStringForResult($object) {
     $cursor = $this->newExternalCursorStringForResult($object);
 
     if (!is_string($cursor)) {
       throw new Exception(
         pht(
           'Expected "newExternalCursorStringForResult()"  in class "%s" to '.
           'return a string, but got "%s".',
           get_class($this),
           phutil_describe_type($cursor)));
     }
 
     return $cursor;
   }
 
   final protected function getExternalCursorString() {
     return $this->externalCursorString;
   }
 
   private function setExternalCursorString($external_cursor) {
     $this->externalCursorString = $external_cursor;
     return $this;
   }
 
   final protected function getIsQueryOrderReversed() {
     return $this->isQueryOrderReversed;
   }
 
   final protected function setIsQueryOrderReversed($is_reversed) {
     $this->isQueryOrderReversed = $is_reversed;
     return $this;
   }
 
   private function getInternalCursorObject() {
     return $this->internalCursorObject;
   }
 
   private function setInternalCursorObject(
     PhabricatorQueryCursor $cursor) {
     $this->internalCursorObject = $cursor;
     return $this;
   }
 
   private function getInternalCursorFromExternalCursor(
     $cursor_string) {
 
     $cursor_object = $this->newInternalCursorFromExternalCursor($cursor_string);
 
     if (!($cursor_object instanceof PhabricatorQueryCursor)) {
       throw new Exception(
         pht(
           'Expected "newInternalCursorFromExternalCursor()" to return an '.
           'object of class "PhabricatorQueryCursor", but got "%s" (in '.
           'class "%s").',
           phutil_describe_type($cursor_object),
           get_class($this)));
     }
 
     return $cursor_object;
   }
 
   private function getPagingMapFromCursorObject(
     PhabricatorQueryCursor $cursor,
     array $keys) {
 
     $map = $this->newPagingMapFromCursorObject($cursor, $keys);
 
     if (!is_array($map)) {
       throw new Exception(
         pht(
           'Expected "newPagingMapFromCursorObject()" to return a map of '.
           'paging values, but got "%s" (in class "%s").',
           phutil_describe_type($map),
           get_class($this)));
     }
 
     if ($this->supportsFerretEngine()) {
       if ($this->hasFerretOrder()) {
         $map += array(
           'rank' =>
             $cursor->getRawRowProperty(self::FULLTEXT_RANK),
           'fulltext-modified' =>
             $cursor->getRawRowProperty(self::FULLTEXT_MODIFIED),
           'fulltext-created' =>
             $cursor->getRawRowProperty(self::FULLTEXT_CREATED),
         );
       }
     }
 
     foreach ($keys as $key) {
       if (!array_key_exists($key, $map)) {
         throw new Exception(
           pht(
             'Map returned by "newPagingMapFromCursorObject()" in class "%s" '.
             'omits required key "%s".',
             get_class($this),
             $key));
       }
     }
 
     return $map;
   }
 
   final protected function nextPage(array $page) {
     if (!$page) {
       return;
     }
 
     $cursor = id(new PhabricatorQueryCursor())
       ->setObject(last($page));
 
     if ($this->rawCursorRow) {
       $cursor->setRawRow($this->rawCursorRow);
     }
 
     $this->setInternalCursorObject($cursor);
   }
 
   final public function getFerretMetadata() {
     if (!$this->supportsFerretEngine()) {
       throw new Exception(
         pht(
           'Unable to retrieve Ferret engine metadata, this class ("%s") does '.
           'not support the Ferret engine.',
           get_class($this)));
     }
 
     return $this->ferretMetadata;
   }
 
   protected function loadPage() {
     $object = $this->newResultObject();
 
     if (!$object instanceof PhabricatorLiskDAO) {
       throw new Exception(
         pht(
           'Query class ("%s") did not return the correct type of object '.
           'from "newResultObject()" (expected a subclass of '.
           '"PhabricatorLiskDAO", found "%s"). Return an object of the '.
           'expected type (this is common), or implement a custom '.
           '"loadPage()" method (this is unusual in modern code).',
           get_class($this),
           phutil_describe_type($object)));
     }
 
     return $this->loadStandardPage($object);
   }
 
   protected function loadStandardPage(PhabricatorLiskDAO $table) {
     $rows = $this->loadStandardPageRows($table);
     return $table->loadAllFromArray($rows);
   }
 
   protected function loadStandardPageRows(PhabricatorLiskDAO $table) {
     $conn = $table->establishConnection('r');
     return $this->loadStandardPageRowsWithConnection(
       $conn,
       $table->getTableName());
   }
 
   protected function loadStandardPageRowsWithConnection(
     AphrontDatabaseConnection $conn,
     $table_name) {
 
     $query = $this->buildStandardPageQuery($conn, $table_name);
 
     $rows = queryfx_all($conn, '%Q', $query);
     $rows = $this->didLoadRawRows($rows);
 
     return $rows;
   }
 
   protected function buildStandardPageQuery(
     AphrontDatabaseConnection $conn,
     $table_name) {
 
     $table_alias = $this->getPrimaryTableAlias();
     if ($table_alias === null) {
       $table_alias = qsprintf($conn, '');
     } else {
       $table_alias = qsprintf($conn, '%T', $table_alias);
     }
 
     return qsprintf(
       $conn,
       '%Q FROM %T %Q %Q %Q %Q %Q %Q %Q',
       $this->buildSelectClause($conn),
       $table_name,
       $table_alias,
       $this->buildJoinClause($conn),
       $this->buildWhereClause($conn),
       $this->buildGroupClause($conn),
       $this->buildHavingClause($conn),
       $this->buildOrderClause($conn),
       $this->buildLimitClause($conn));
   }
 
   protected function didLoadRawRows(array $rows) {
     $this->rawCursorRow = last($rows);
 
     if ($this->ferretEngine) {
       foreach ($rows as $row) {
         $phid = $row['phid'];
 
         $metadata = id(new PhabricatorFerretMetadata())
           ->setPHID($phid)
           ->setEngine($this->ferretEngine)
           ->setRelevance(idx($row, self::FULLTEXT_RANK));
 
         $this->ferretMetadata[$phid] = $metadata;
 
         unset($row[self::FULLTEXT_RANK]);
         unset($row[self::FULLTEXT_MODIFIED]);
         unset($row[self::FULLTEXT_CREATED]);
       }
     }
 
     return $rows;
   }
 
   final protected function buildLimitClause(AphrontDatabaseConnection $conn) {
     if ($this->shouldLimitResults()) {
       $limit = $this->getRawResultLimit();
       if ($limit) {
         return qsprintf($conn, 'LIMIT %d', $limit);
       }
     }
 
     return qsprintf($conn, '');
   }
 
   protected function shouldLimitResults() {
     return true;
   }
 
   final protected function didLoadResults(array $results) {
     if ($this->getIsQueryOrderReversed()) {
       $results = array_reverse($results, $preserve_keys = true);
     }
 
     return $results;
   }
 
   final public function newIterator() {
     return new PhabricatorQueryIterator($this);
   }
 
   final public function executeWithCursorPager(AphrontCursorPagerView $pager) {
     $limit = $pager->getPageSize();
 
     $this->setLimit($limit + 1);
 
     $after_id = phutil_string_cast($pager->getAfterID());
     $before_id = phutil_string_cast($pager->getBeforeID());
 
     if (phutil_nonempty_string($after_id)) {
       $this->setExternalCursorString($after_id);
     } else if (phutil_nonempty_string($before_id)) {
       $this->setExternalCursorString($before_id);
       $this->setIsQueryOrderReversed(true);
     }
 
     $results = $this->execute();
     $count = count($results);
 
     $sliced_results = $pager->sliceResults($results);
     if ($sliced_results) {
 
       // If we have results, generate external-facing cursors from the visible
       // results. This stops us from leaking any internal details about objects
       // which we loaded but which were not visible to the viewer.
 
       if ($pager->getBeforeID() || ($count > $limit)) {
         $last_object = last($sliced_results);
         $cursor = $this->getExternalCursorStringForResult($last_object);
         $pager->setNextPageID($cursor);
       }
 
       if ($pager->getAfterID() ||
          ($pager->getBeforeID() && ($count > $limit))) {
         $head_object = head($sliced_results);
         $cursor = $this->getExternalCursorStringForResult($head_object);
         $pager->setPrevPageID($cursor);
       }
     }
 
     return $sliced_results;
   }
 
 
   /**
    * Return the alias this query uses to identify the primary table.
    *
    * Some automatic query constructions may need to be qualified with a table
    * alias if the query performs joins which make column names ambiguous. If
    * this is the case, return the alias for the primary table the query
    * uses; generally the object table which has `id` and `phid` columns.
    *
    * @return string Alias for the primary table.
    */
   protected function getPrimaryTableAlias() {
     return null;
   }
 
   public function newResultObject() {
     return null;
   }
 
 
 /* -(  Building Query Clauses  )--------------------------------------------- */
 
 
   /**
    * @task clauses
    */
   protected function buildSelectClause(AphrontDatabaseConnection $conn) {
     $parts = $this->buildSelectClauseParts($conn);
     return $this->formatSelectClause($conn, $parts);
   }
 
 
   /**
    * @task clauses
    */
   protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) {
     $select = array();
 
     $alias = $this->getPrimaryTableAlias();
     if ($alias) {
       $select[] = qsprintf($conn, '%T.*', $alias);
     } else {
       $select[] = qsprintf($conn, '*');
     }
 
     $select[] = $this->buildEdgeLogicSelectClause($conn);
     $select[] = $this->buildFerretSelectClause($conn);
 
     return $select;
   }
 
 
   /**
    * @task clauses
    */
   protected function buildJoinClause(AphrontDatabaseConnection $conn) {
     $joins = $this->buildJoinClauseParts($conn);
     return $this->formatJoinClause($conn, $joins);
   }
 
 
   /**
    * @task clauses
    */
   protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
     $joins = array();
     $joins[] = $this->buildEdgeLogicJoinClause($conn);
     $joins[] = $this->buildApplicationSearchJoinClause($conn);
     $joins[] = $this->buildNgramsJoinClause($conn);
     $joins[] = $this->buildFerretJoinClause($conn);
     return $joins;
   }
 
 
   /**
    * @task clauses
    */
   protected function buildWhereClause(AphrontDatabaseConnection $conn) {
     $where = $this->buildWhereClauseParts($conn);
     return $this->formatWhereClause($conn, $where);
   }
 
 
   /**
    * @task clauses
    */
   protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
     $where = array();
     $where[] = $this->buildPagingWhereClause($conn);
     $where[] = $this->buildEdgeLogicWhereClause($conn);
     $where[] = $this->buildSpacesWhereClause($conn);
     $where[] = $this->buildNgramsWhereClause($conn);
     $where[] = $this->buildFerretWhereClause($conn);
     $where[] = $this->buildApplicationSearchWhereClause($conn);
     return $where;
   }
 
 
   /**
    * @task clauses
    */
   protected function buildHavingClause(AphrontDatabaseConnection $conn) {
     $having = $this->buildHavingClauseParts($conn);
     $having[] = $this->buildPagingHavingClause($conn);
     return $this->formatHavingClause($conn, $having);
   }
 
 
   /**
    * @task clauses
    */
   protected function buildHavingClauseParts(AphrontDatabaseConnection $conn) {
     $having = array();
     $having[] = $this->buildEdgeLogicHavingClause($conn);
     return $having;
   }
 
 
   /**
    * @task clauses
    */
   protected function buildGroupClause(AphrontDatabaseConnection $conn) {
     if (!$this->shouldGroupQueryResultRows()) {
       return qsprintf($conn, '');
     }
 
     return qsprintf(
       $conn,
       'GROUP BY %Q',
       $this->getApplicationSearchObjectPHIDColumn($conn));
   }
 
 
   /**
    * @task clauses
    */
   protected function shouldGroupQueryResultRows() {
     if ($this->shouldGroupEdgeLogicResultRows()) {
       return true;
     }
 
     if ($this->getApplicationSearchMayJoinMultipleRows()) {
       return true;
     }
 
     if ($this->shouldGroupNgramResultRows()) {
       return true;
     }
 
     if ($this->shouldGroupFerretResultRows()) {
       return true;
     }
 
     return false;
   }
 
 
 
 /* -(  Paging  )------------------------------------------------------------- */
 
 
   private function buildPagingWhereClause(AphrontDatabaseConnection $conn) {
     if ($this->shouldPageWithHavingClause()) {
       return null;
     }
 
     return $this->buildPagingClause($conn);
   }
 
   private function buildPagingHavingClause(AphrontDatabaseConnection $conn) {
     if (!$this->shouldPageWithHavingClause()) {
       return null;
     }
 
     return $this->buildPagingClause($conn);
   }
 
   private function shouldPageWithHavingClause() {
     // If any of the paging conditions reference dynamic columns, we need to
     // put the paging conditions in a "HAVING" clause instead of a "WHERE"
     // clause.
 
     // For example, this happens when paging on the Ferret "rank" column,
     // since the "rank" value is computed dynamically in the SELECT statement.
 
     $orderable = $this->getOrderableColumns();
     $vector = $this->getOrderVector();
 
     foreach ($vector as $order) {
       $key = $order->getOrderKey();
       $column = $orderable[$key];
 
       if (!empty($column['having'])) {
         return true;
       }
     }
 
     return false;
   }
 
   /**
    * @task paging
    */
   protected function buildPagingClause(AphrontDatabaseConnection $conn) {
     $orderable = $this->getOrderableColumns();
     $vector = $this->getQueryableOrderVector();
 
     // If we don't have a cursor object yet, it means we're trying to load
     // the first result page. We may need to build a cursor object from the
     // external string, or we may not need a paging clause yet.
     $cursor_object = $this->getInternalCursorObject();
     if (!$cursor_object) {
       $external_cursor = $this->getExternalCursorString();
       if ($external_cursor !== null) {
         $cursor_object = $this->getInternalCursorFromExternalCursor(
           $external_cursor);
       }
     }
 
     // If we still don't have a cursor object, this is the first result page
     // and we aren't paging it. We don't need to build a paging clause.
     if (!$cursor_object) {
       return qsprintf($conn, '');
     }
 
     $reversed = $this->getIsQueryOrderReversed();
 
     $keys = array();
     foreach ($vector as $order) {
       $keys[] = $order->getOrderKey();
     }
     $keys = array_fuse($keys);
 
     $value_map = $this->getPagingMapFromCursorObject(
       $cursor_object,
       $keys);
 
     $columns = array();
     foreach ($vector as $order) {
       $key = $order->getOrderKey();
 
       $column = $orderable[$key];
       $column['value'] = $value_map[$key];
 
       // If the vector component is reversed, we need to reverse whatever the
       // order of the column is.
       if ($order->getIsReversed()) {
         $column['reverse'] = !idx($column, 'reverse', false);
       }
 
       $columns[] = $column;
     }
 
     return $this->buildPagingClauseFromMultipleColumns(
       $conn,
       $columns,
       array(
         'reversed' => $reversed,
       ));
   }
 
 
   /**
    * Simplifies the task of constructing a paging clause across multiple
    * columns. In the general case, this looks like:
    *
    *   A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c)
    *
    * To build a clause, specify the name, type, and value of each column
    * to include:
    *
    *   $this->buildPagingClauseFromMultipleColumns(
    *     $conn_r,
    *     array(
    *       array(
    *         'table' => 't',
    *         'column' => 'title',
    *         'type' => 'string',
    *         'value' => $cursor->getTitle(),
    *         'reverse' => true,
    *       ),
    *       array(
    *         'table' => 't',
    *         'column' => 'id',
    *         'type' => 'int',
    *         'value' => $cursor->getID(),
    *       ),
    *     ),
    *     array(
    *       'reversed' => $is_reversed,
    *     ));
    *
    * This method will then return a composable clause for inclusion in WHERE.
    *
-   * @param AphrontDatabaseConnection Connection query will execute on.
-   * @param list<map> Column description dictionaries.
-   * @param map Additional construction options.
+   * @param AphrontDatabaseConnection $conn Connection query will execute on.
+   * @param list<map> $columns Column description dictionaries.
+   * @param map $options Additional construction options.
    * @return string Query clause.
    * @task paging
    */
   final protected function buildPagingClauseFromMultipleColumns(
     AphrontDatabaseConnection $conn,
     array $columns,
     array $options) {
 
     foreach ($columns as $column) {
       PhutilTypeSpec::checkMap(
         $column,
         array(
           'table' => 'optional string|null',
           'column' => 'string',
           'customfield' => 'optional bool',
           'customfield.index.key' => 'optional string',
           'customfield.index.table' => 'optional string',
           'value' => 'wild',
           'type' => 'string',
           'reverse' => 'optional bool',
           'unique' => 'optional bool',
           'null' => 'optional string|null',
           'requires-ferret' => 'optional bool',
           'having' => 'optional bool',
         ));
     }
 
     PhutilTypeSpec::checkMap(
       $options,
       array(
         'reversed' => 'optional bool',
       ));
 
     $is_query_reversed = idx($options, 'reversed', false);
 
     $clauses = array();
     $accumulated = array();
     $last_key = last_key($columns);
     foreach ($columns as $key => $column) {
       $type = $column['type'];
 
       $null = idx($column, 'null');
       if ($column['value'] === null) {
         if ($null) {
           $value = null;
         } else {
           throw new Exception(
             pht(
               'Column "%s" has null value, but does not specify a null '.
               'behavior.',
               $key));
         }
       } else {
         switch ($type) {
           case 'int':
             $value = qsprintf($conn, '%d', $column['value']);
             break;
           case 'float':
             $value = qsprintf($conn, '%f', $column['value']);
             break;
           case 'string':
             $value = qsprintf($conn, '%s', $column['value']);
             break;
           default:
             throw new Exception(
               pht(
                 'Column "%s" has unknown column type "%s".',
                 $column['column'],
                 $type));
         }
       }
 
       $is_column_reversed = idx($column, 'reverse', false);
       $reverse = ($is_query_reversed xor $is_column_reversed);
 
       $clause = $accumulated;
 
       $table_name = idx($column, 'table');
       $column_name = $column['column'];
       if ($table_name !== null) {
         $field = qsprintf($conn, '%T.%T', $table_name, $column_name);
       } else {
         $field = qsprintf($conn, '%T', $column_name);
       }
 
       $parts = array();
       if ($null) {
         $can_page_if_null = ($null === 'head');
         $can_page_if_nonnull = ($null === 'tail');
 
         if ($reverse) {
           $can_page_if_null = !$can_page_if_null;
           $can_page_if_nonnull = !$can_page_if_nonnull;
         }
 
         $subclause = null;
         if ($can_page_if_null && $value === null) {
           $parts[] = qsprintf(
             $conn,
             '(%Q IS NOT NULL)',
             $field);
         } else if ($can_page_if_nonnull && $value !== null) {
           $parts[] = qsprintf(
             $conn,
             '(%Q IS NULL)',
             $field);
         }
       }
 
       if ($value !== null) {
         $parts[] = qsprintf(
           $conn,
           '%Q %Q %Q',
           $field,
           $reverse ? qsprintf($conn, '>') : qsprintf($conn, '<'),
           $value);
       }
 
       if ($parts) {
         $clause[] = qsprintf($conn, '%LO', $parts);
       }
 
       if ($clause) {
         $clauses[] = qsprintf($conn, '%LA', $clause);
       }
 
       if ($value === null) {
         $accumulated[] = qsprintf(
           $conn,
           '%Q IS NULL',
           $field);
       } else {
         $accumulated[] = qsprintf(
           $conn,
           '%Q = %Q',
           $field,
           $value);
       }
     }
 
     if ($clauses) {
       return qsprintf($conn, '%LO', $clauses);
     }
 
     return qsprintf($conn, '');
   }
 
 
 /* -(  Result Ordering  )---------------------------------------------------- */
 
 
   /**
    * Select a result ordering.
    *
    * This is a high-level method which selects an ordering from a predefined
    * list of builtin orders, as provided by @{method:getBuiltinOrders}. These
    * options are user-facing and not exhaustive, but are generally convenient
    * and meaningful.
    *
    * You can also use @{method:setOrderVector} to specify a low-level ordering
    * across individual orderable columns. This offers greater control but is
    * also more involved.
    *
-   * @param string Key of a builtin order supported by this query.
+   * @param string $order Key of a builtin order supported by this query.
    * @return this
    * @task order
    */
   public function setOrder($order) {
     $aliases = $this->getBuiltinOrderAliasMap();
 
     if (empty($aliases[$order])) {
       throw new Exception(
         pht(
           'Query "%s" does not support a builtin order "%s". Supported orders '.
           'are: %s.',
           get_class($this),
           $order,
           implode(', ', array_keys($aliases))));
     }
 
     $this->builtinOrder = $aliases[$order];
     $this->orderVector = null;
 
     return $this;
   }
 
 
   /**
    * Set a grouping order to apply before primary result ordering.
    *
    * This allows you to preface the query order vector with additional orders,
    * so you can effect "group by" queries while still respecting "order by".
    *
    * This is a high-level method which works alongside @{method:setOrder}. For
    * lower-level control over order vectors, use @{method:setOrderVector}.
    *
-   * @param PhabricatorQueryOrderVector|list<string> List of order keys.
+   * @param PhabricatorQueryOrderVector|list<string> $vector List of order
+   *   keys.
    * @return this
    * @task order
    */
   public function setGroupVector($vector) {
     $this->groupVector = $vector;
     $this->orderVector = null;
 
     return $this;
   }
 
 
   /**
    * Get builtin orders for this class.
    *
    * In application UIs, we want to be able to present users with a small
    * selection of meaningful order options (like "Order by Title") rather than
    * an exhaustive set of column ordering options.
    *
    * Meaningful user-facing orders are often really orders across multiple
    * columns: for example, a "title" ordering is usually implemented as a
    * "title, id" ordering under the hood.
    *
    * Builtin orders provide a mapping from convenient, understandable
    * user-facing orders to implementations.
    *
    * A builtin order should provide these keys:
    *
    *   - `vector` (`list<string>`): The actual order vector to use.
    *   - `name` (`string`): Human-readable order name.
    *
    * @return map<string, wild> Map from builtin order keys to specification.
    * @task order
    */
   public function getBuiltinOrders() {
     $orders = array(
       'newest' => array(
         'vector' => array('id'),
         'name' => pht('Creation (Newest First)'),
         'aliases' => array('created'),
       ),
       'oldest' => array(
         'vector' => array('-id'),
         'name' => pht('Creation (Oldest First)'),
       ),
     );
 
     $object = $this->newResultObject();
     if ($object instanceof PhabricatorCustomFieldInterface) {
       $list = PhabricatorCustomField::getObjectFields(
         $object,
         PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
       foreach ($list->getFields() as $field) {
         $index = $field->buildOrderIndex();
         if (!$index) {
           continue;
         }
 
         $legacy_key = 'custom:'.$field->getFieldKey();
         $modern_key = $field->getModernFieldKey();
 
         $orders[$modern_key] = array(
           'vector' => array($modern_key, 'id'),
           'name' => $field->getFieldName(),
           'aliases' => array($legacy_key),
         );
 
         $orders['-'.$modern_key] = array(
           'vector' => array('-'.$modern_key, '-id'),
           'name' => pht('%s (Reversed)', $field->getFieldName()),
         );
       }
     }
 
     if ($this->supportsFerretEngine()) {
       $orders['relevance'] = array(
         'vector' => array('rank', 'fulltext-modified', 'id'),
         'name' => pht('Relevance'),
       );
     }
 
     return $orders;
   }
 
   public function getBuiltinOrderAliasMap() {
     $orders = $this->getBuiltinOrders();
 
     $map = array();
     foreach ($orders as $key => $order) {
       $keys = array();
       $keys[] = $key;
       foreach (idx($order, 'aliases', array()) as $alias) {
         $keys[] = $alias;
       }
 
       foreach ($keys as $alias) {
         if (isset($map[$alias])) {
           throw new Exception(
             pht(
               'Two builtin orders ("%s" and "%s") define the same key or '.
               'alias ("%s"). Each order alias and key must be unique and '.
               'identify a single order.',
               $key,
               $map[$alias],
               $alias));
         }
         $map[$alias] = $key;
       }
     }
 
     return $map;
   }
 
 
   /**
    * Set a low-level column ordering.
    *
    * This is a low-level method which offers granular control over column
    * ordering. In most cases, applications can more easily use
    * @{method:setOrder} to choose a high-level builtin order.
    *
    * To set an order vector, specify a list of order keys as provided by
    * @{method:getOrderableColumns}.
    *
-   * @param PhabricatorQueryOrderVector|list<string> List of order keys.
+   * @param PhabricatorQueryOrderVector|list<string> $vector List of order
+   *   keys.
    * @return this
    * @task order
    */
   public function setOrderVector($vector) {
     $vector = PhabricatorQueryOrderVector::newFromVector($vector);
 
     $orderable = $this->getOrderableColumns();
 
     // Make sure that all the components identify valid columns.
     $unique = array();
     foreach ($vector as $order) {
       $key = $order->getOrderKey();
       if (empty($orderable[$key])) {
         $valid = implode(', ', array_keys($orderable));
         throw new Exception(
           pht(
             'This query ("%s") does not support sorting by order key "%s". '.
             'Supported orders are: %s.',
             get_class($this),
             $key,
             $valid));
       }
 
       $unique[$key] = idx($orderable[$key], 'unique', false);
     }
 
     // Make sure that the last column is unique so that this is a strong
     // ordering which can be used for paging.
     $last = last($unique);
     if ($last !== true) {
       throw new Exception(
         pht(
           'Order vector "%s" is invalid: the last column in an order must '.
           'be a column with unique values, but "%s" is not unique.',
           $vector->getAsString(),
           last_key($unique)));
     }
 
     // Make sure that other columns are not unique; an ordering like "id, name"
     // does not make sense because only "id" can ever have an effect.
     array_pop($unique);
     foreach ($unique as $key => $is_unique) {
       if ($is_unique) {
         throw new Exception(
           pht(
             'Order vector "%s" is invalid: only the last column in an order '.
             'may be unique, but "%s" is a unique column and not the last '.
             'column in the order.',
             $vector->getAsString(),
             $key));
       }
     }
 
     $this->orderVector = $vector;
     return $this;
   }
 
 
   /**
    * Get the effective order vector.
    *
    * @return PhabricatorQueryOrderVector Effective vector.
    * @task order
    */
   protected function getOrderVector() {
     if (!$this->orderVector) {
       if ($this->builtinOrder !== null) {
         $builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder);
         $vector = $builtin_order['vector'];
       } else {
         $vector = $this->getDefaultOrderVector();
       }
 
       if ($this->groupVector) {
         $group = PhabricatorQueryOrderVector::newFromVector($this->groupVector);
         $group->appendVector($vector);
         $vector = $group;
       }
 
       $vector = PhabricatorQueryOrderVector::newFromVector($vector);
 
       // We call setOrderVector() here to apply checks to the default vector.
       // This catches any errors in the implementation.
       $this->setOrderVector($vector);
     }
 
     return $this->orderVector;
   }
 
 
   /**
    * @task order
    */
   protected function getDefaultOrderVector() {
     return array('id');
   }
 
 
   /**
    * @task order
    */
   public function getOrderableColumns() {
     $cache = PhabricatorCaches::getRequestCache();
     $class = get_class($this);
     $cache_key = 'query.orderablecolumns.'.$class;
 
     $columns = $cache->getKey($cache_key);
     if ($columns !== null) {
       return $columns;
     }
 
     $columns = array(
       'id' => array(
         'table' => $this->getPrimaryTableAlias(),
         'column' => 'id',
         'reverse' => false,
         'type' => 'int',
         'unique' => true,
       ),
     );
 
     $object = $this->newResultObject();
     if ($object instanceof PhabricatorCustomFieldInterface) {
       $list = PhabricatorCustomField::getObjectFields(
         $object,
         PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
       foreach ($list->getFields() as $field) {
         $index = $field->buildOrderIndex();
         if (!$index) {
           continue;
         }
 
         $digest = $field->getFieldIndex();
 
         $key = $field->getModernFieldKey();
 
         $columns[$key] = array(
           'table' => 'appsearch_order_'.$digest,
           'column' => 'indexValue',
           'type' => $index->getIndexValueType(),
           'null' => 'tail',
           'customfield' => true,
           'customfield.index.table' => $index->getTableName(),
           'customfield.index.key' => $digest,
         );
       }
     }
 
     if ($this->supportsFerretEngine()) {
       $columns['rank'] = array(
         'table' => null,
         'column' => self::FULLTEXT_RANK,
         'type' => 'int',
         'requires-ferret' => true,
         'having' => true,
       );
       $columns['fulltext-created'] = array(
         'table' => null,
         'column' => self::FULLTEXT_CREATED,
         'type' => 'int',
         'requires-ferret' => true,
       );
       $columns['fulltext-modified'] = array(
         'table' => null,
         'column' => self::FULLTEXT_MODIFIED,
         'type' => 'int',
         'requires-ferret' => true,
       );
     }
 
     $cache->setKey($cache_key, $columns);
 
     return $columns;
   }
 
 
   /**
    * @task order
    */
   final protected function buildOrderClause(
     AphrontDatabaseConnection $conn,
     $for_union = false) {
 
     $orderable = $this->getOrderableColumns();
     $vector = $this->getQueryableOrderVector();
 
     $parts = array();
     foreach ($vector as $order) {
       $part = $orderable[$order->getOrderKey()];
 
       if ($order->getIsReversed()) {
         $part['reverse'] = !idx($part, 'reverse', false);
       }
       $parts[] = $part;
     }
 
     return $this->formatOrderClause($conn, $parts, $for_union);
   }
 
   /**
    * @task order
    */
   private function getQueryableOrderVector() {
     $vector = $this->getOrderVector();
     $orderable = $this->getOrderableColumns();
 
     $keep = array();
     foreach ($vector as $order) {
       $column = $orderable[$order->getOrderKey()];
 
       // If this is a Ferret fulltext column but the query doesn't actually
       // have a fulltext query, we'll skip most of the Ferret stuff and won't
       // actually have the columns in the result set. Just skip them.
       if (!empty($column['requires-ferret'])) {
         if (!$this->getFerretTokens()) {
           continue;
         }
       }
 
       $keep[] = $order->getAsScalar();
     }
 
     return PhabricatorQueryOrderVector::newFromVector($keep);
   }
 
   /**
    * @task order
    */
   protected function formatOrderClause(
     AphrontDatabaseConnection $conn,
     array $parts,
     $for_union = false) {
 
     $is_query_reversed = $this->getIsQueryOrderReversed();
 
     $sql = array();
     foreach ($parts as $key => $part) {
       $is_column_reversed = !empty($part['reverse']);
 
       $descending = true;
       if ($is_query_reversed) {
         $descending = !$descending;
       }
 
       if ($is_column_reversed) {
         $descending = !$descending;
       }
 
       $table = idx($part, 'table');
 
       // When we're building an ORDER BY clause for a sequence of UNION
       // statements, we can't refer to tables from the subqueries.
       if ($for_union) {
         $table = null;
       }
 
       $column = $part['column'];
 
       if ($table !== null) {
         $field = qsprintf($conn, '%T.%T', $table, $column);
       } else {
         $field = qsprintf($conn, '%T', $column);
       }
 
       $null = idx($part, 'null');
       if ($null) {
         switch ($null) {
           case 'head':
             $null_field = qsprintf($conn, '(%Q IS NULL)', $field);
             break;
           case 'tail':
             $null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field);
             break;
           default:
             throw new Exception(
               pht(
                 'NULL value "%s" is invalid. Valid values are "head" and '.
                 '"tail".',
                 $null));
         }
 
         if ($descending) {
           $sql[] = qsprintf($conn, '%Q DESC', $null_field);
         } else {
           $sql[] = qsprintf($conn, '%Q ASC', $null_field);
         }
       }
 
       if ($descending) {
         $sql[] = qsprintf($conn, '%Q DESC', $field);
       } else {
         $sql[] = qsprintf($conn, '%Q ASC', $field);
       }
     }
 
     return qsprintf($conn, 'ORDER BY %LQ', $sql);
   }
 
 
 /* -(  Application Search  )------------------------------------------------- */
 
 
   /**
    * Constrain the query with an ApplicationSearch index, requiring field values
    * contain at least one of the values in a set.
    *
    * This constraint can build the most common types of queries, like:
    *
    *   - Find users with shirt sizes "X" or "XL".
    *   - Find shoes with size "13".
    *
-   * @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
-   * @param string|list<string> One or more values to filter by.
+   * @param PhabricatorCustomFieldIndexStorage $index Table where the index is
+   *   stored.
+   * @param string|list<string> $value One or more values to filter by.
    * @return this
    * @task appsearch
    */
   public function withApplicationSearchContainsConstraint(
     PhabricatorCustomFieldIndexStorage $index,
     $value) {
 
     $values = (array)$value;
 
     $data_values = array();
     $constraint_values = array();
     foreach ($values as $value) {
       if ($value instanceof PhabricatorQueryConstraint) {
         $constraint_values[] = $value;
       } else {
         $data_values[] = $value;
       }
     }
 
     $alias = 'appsearch_'.count($this->applicationSearchConstraints);
 
     $this->applicationSearchConstraints[] = array(
       'type'  => $index->getIndexValueType(),
       'cond'  => '=',
       'table' => $index->getTableName(),
       'index' => $index->getIndexKey(),
       'alias' => $alias,
       'value' => $values,
       'data' => $data_values,
       'constraints' => $constraint_values,
     );
 
     return $this;
   }
 
 
   /**
    * Constrain the query with an ApplicationSearch index, requiring values
    * exist in a given range.
    *
    * This constraint is useful for expressing date ranges:
    *
    *   - Find events between July 1st and July 7th.
    *
    * The ends of the range are inclusive, so a `$min` of `3` and a `$max` of
    * `5` will match fields with values `3`, `4`, or `5`. Providing `null` for
    * either end of the range will leave that end of the constraint open.
    *
-   * @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
-   * @param int|null Minimum permissible value, inclusive.
-   * @param int|null Maximum permissible value, inclusive.
+   * @param PhabricatorCustomFieldIndexStorage $index Table where the index is
+   *   stored.
+   * @param int|null $min Minimum permissible value, inclusive.
+   * @param int|null $max Maximum permissible value, inclusive.
    * @return this
    * @task appsearch
    */
   public function withApplicationSearchRangeConstraint(
     PhabricatorCustomFieldIndexStorage $index,
     $min,
     $max) {
 
     $index_type = $index->getIndexValueType();
     if ($index_type != 'int') {
       throw new Exception(
         pht(
           'Attempting to apply a range constraint to a field with index type '.
           '"%s", expected type "%s".',
           $index_type,
           'int'));
     }
 
     $alias = 'appsearch_'.count($this->applicationSearchConstraints);
 
     $this->applicationSearchConstraints[] = array(
       'type' => $index->getIndexValueType(),
       'cond' => 'range',
       'table' => $index->getTableName(),
       'index' => $index->getIndexKey(),
       'alias' => $alias,
       'value' => array($min, $max),
       'data' => null,
       'constraints' => null,
     );
 
     return $this;
   }
 
 
   /**
    * Get the name of the query's primary object PHID column, for constructing
    * JOIN clauses. Normally (and by default) this is just `"phid"`, but it may
    * be something more exotic.
    *
    * See @{method:getPrimaryTableAlias} if the column needs to be qualified with
    * a table alias.
    *
-   * @param AphrontDatabaseConnection Connection executing queries.
+   * @param AphrontDatabaseConnection $conn Connection executing queries.
    * @return PhutilQueryString Column name.
    * @task appsearch
    */
   protected function getApplicationSearchObjectPHIDColumn(
     AphrontDatabaseConnection $conn) {
 
     if ($this->getPrimaryTableAlias()) {
       return qsprintf($conn, '%T.phid', $this->getPrimaryTableAlias());
     } else {
       return qsprintf($conn, 'phid');
     }
   }
 
 
   /**
    * Determine if the JOINs built by ApplicationSearch might cause each primary
    * object to return multiple result rows. Generally, this means the query
    * needs an extra GROUP BY clause.
    *
    * @return bool True if the query may return multiple rows for each object.
    * @task appsearch
    */
   protected function getApplicationSearchMayJoinMultipleRows() {
     foreach ($this->applicationSearchConstraints as $constraint) {
       $type = $constraint['type'];
       $value = $constraint['value'];
       $cond = $constraint['cond'];
 
       switch ($cond) {
         case '=':
           switch ($type) {
             case 'string':
             case 'int':
               if (count($value) > 1) {
                 return true;
               }
               break;
             default:
               throw new Exception(pht('Unknown index type "%s"!', $type));
           }
           break;
         case 'range':
           // NOTE: It's possible to write a custom field where multiple rows
           // match a range constraint, but we don't currently ship any in the
           // upstream and I can't immediately come up with cases where this
           // would make sense.
           break;
         default:
           throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
       }
     }
 
     return false;
   }
 
 
   /**
    * Construct a GROUP BY clause appropriate for ApplicationSearch constraints.
    *
-   * @param AphrontDatabaseConnection Connection executing the query.
+   * @param AphrontDatabaseConnection $conn Connection executing the query.
    * @return string Group clause.
    * @task appsearch
    */
   protected function buildApplicationSearchGroupClause(
     AphrontDatabaseConnection $conn) {
 
     if ($this->getApplicationSearchMayJoinMultipleRows()) {
       return qsprintf(
         $conn,
         'GROUP BY %Q',
         $this->getApplicationSearchObjectPHIDColumn($conn));
     } else {
       return qsprintf($conn, '');
     }
   }
 
 
   /**
    * Construct a JOIN clause appropriate for applying ApplicationSearch
    * constraints.
    *
-   * @param AphrontDatabaseConnection Connection executing the query.
+   * @param AphrontDatabaseConnection $conn Connection executing the query.
    * @return string Join clause.
    * @task appsearch
    */
   protected function buildApplicationSearchJoinClause(
     AphrontDatabaseConnection $conn) {
 
     $joins = array();
     foreach ($this->applicationSearchConstraints as $key => $constraint) {
       $table = $constraint['table'];
       $alias = $constraint['alias'];
       $index = $constraint['index'];
       $cond = $constraint['cond'];
       $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
       switch ($cond) {
         case '=':
           // Figure out whether we need to do a LEFT JOIN or not. We need to
           // LEFT JOIN if we're going to select "IS NULL" rows.
           $join_type = qsprintf($conn, 'JOIN');
           foreach ($constraint['constraints'] as $query_constraint) {
             $op = $query_constraint->getOperator();
             if ($op === PhabricatorQueryConstraint::OPERATOR_NULL) {
               $join_type = qsprintf($conn, 'LEFT JOIN');
               break;
             }
           }
 
           $joins[] = qsprintf(
             $conn,
             '%Q %T %T ON %T.objectPHID = %Q
               AND %T.indexKey = %s',
             $join_type,
             $table,
             $alias,
             $alias,
             $phid_column,
             $alias,
             $index);
           break;
         case 'range':
           list($min, $max) = $constraint['value'];
           if (($min === null) && ($max === null)) {
             // If there's no actual range constraint, just move on.
             break;
           }
 
           if ($min === null) {
             $constraint_clause = qsprintf(
               $conn,
               '%T.indexValue <= %d',
               $alias,
               $max);
           } else if ($max === null) {
             $constraint_clause = qsprintf(
               $conn,
               '%T.indexValue >= %d',
               $alias,
               $min);
           } else {
             $constraint_clause = qsprintf(
               $conn,
               '%T.indexValue BETWEEN %d AND %d',
               $alias,
               $min,
               $max);
           }
 
           $joins[] = qsprintf(
             $conn,
             'JOIN %T %T ON %T.objectPHID = %Q
               AND %T.indexKey = %s
               AND (%Q)',
             $table,
             $alias,
             $alias,
             $phid_column,
             $alias,
             $index,
             $constraint_clause);
           break;
         default:
           throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
       }
     }
 
     $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
     $orderable = $this->getOrderableColumns();
 
     $vector = $this->getOrderVector();
     foreach ($vector as $order) {
       $spec = $orderable[$order->getOrderKey()];
       if (empty($spec['customfield'])) {
         continue;
       }
 
       $table = $spec['customfield.index.table'];
       $alias = $spec['table'];
       $key = $spec['customfield.index.key'];
 
       $joins[] = qsprintf(
         $conn,
         'LEFT JOIN %T %T ON %T.objectPHID = %Q
           AND %T.indexKey = %s',
         $table,
         $alias,
         $alias,
         $phid_column,
         $alias,
         $key);
     }
 
     if ($joins) {
       return qsprintf($conn, '%LJ', $joins);
     } else {
       return qsprintf($conn, '');
     }
   }
 
   /**
    * Construct a WHERE clause appropriate for applying ApplicationSearch
    * constraints.
    *
-   * @param AphrontDatabaseConnection Connection executing the query.
+   * @param AphrontDatabaseConnection $conn Connection executing the query.
    * @return list<string> Where clause parts.
    * @task appsearch
    */
   protected function buildApplicationSearchWhereClause(
     AphrontDatabaseConnection $conn) {
 
     $where = array();
 
     foreach ($this->applicationSearchConstraints as $key => $constraint) {
       $alias = $constraint['alias'];
       $cond = $constraint['cond'];
       $type = $constraint['type'];
 
       $data_values = $constraint['data'];
       $constraint_values = $constraint['constraints'];
 
       $constraint_parts = array();
       switch ($cond) {
         case '=':
           if ($data_values) {
             switch ($type) {
               case 'string':
                 $constraint_parts[] = qsprintf(
                   $conn,
                   '%T.indexValue IN (%Ls)',
                   $alias,
                   $data_values);
                 break;
               case 'int':
                 $constraint_parts[] = qsprintf(
                   $conn,
                   '%T.indexValue IN (%Ld)',
                   $alias,
                   $data_values);
                 break;
               default:
                 throw new Exception(pht('Unknown index type "%s"!', $type));
             }
           }
 
           if ($constraint_values) {
             foreach ($constraint_values as $value) {
               $op = $value->getOperator();
               switch ($op) {
                 case PhabricatorQueryConstraint::OPERATOR_NULL:
                   $constraint_parts[] = qsprintf(
                     $conn,
                     '%T.indexValue IS NULL',
                     $alias);
                   break;
                 case PhabricatorQueryConstraint::OPERATOR_ANY:
                   $constraint_parts[] = qsprintf(
                     $conn,
                     '%T.indexValue IS NOT NULL',
                     $alias);
                   break;
                 default:
                   throw new Exception(
                     pht(
                       'No support for applying operator "%s" against '.
                       'index of type "%s".',
                       $op,
                       $type));
               }
             }
           }
 
           if ($constraint_parts) {
             $where[] = qsprintf($conn, '%LO', $constraint_parts);
           }
           break;
       }
     }
 
     return $where;
   }
 
 
 /* -(  Integration with CustomField  )--------------------------------------- */
 
 
   /**
    * @task customfield
    */
   protected function getPagingValueMapForCustomFields(
     PhabricatorCustomFieldInterface $object) {
 
     // We have to get the current field values on the cursor object.
     $fields = PhabricatorCustomField::getObjectFields(
       $object,
       PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
     $fields->setViewer($this->getViewer());
     $fields->readFieldsFromStorage($object);
 
     $map = array();
     foreach ($fields->getFields() as $field) {
       $map[$field->getModernFieldKey()] = $field->getValueForStorage();
     }
 
     return $map;
   }
 
 
   /**
    * @task customfield
    */
   protected function isCustomFieldOrderKey($key) {
     $prefix = 'custom.';
     return !strncmp($key, $prefix, strlen($prefix));
   }
 
 
 /* -(  Ferret  )------------------------------------------------------------- */
 
 
   public function supportsFerretEngine() {
     $object = $this->newResultObject();
     return ($object instanceof PhabricatorFerretInterface);
   }
 
   public function withFerretQuery(
     PhabricatorFerretEngine $engine,
     PhabricatorSavedQuery $query) {
 
     if (!$this->supportsFerretEngine()) {
       throw new Exception(
         pht(
           'Query ("%s") does not support the Ferret fulltext engine.',
           get_class($this)));
     }
 
     $this->ferretEngine = $engine;
     $this->ferretQuery = $query;
 
     return $this;
   }
 
   public function getFerretTokens() {
     if (!$this->supportsFerretEngine()) {
       throw new Exception(
         pht(
           'Query ("%s") does not support the Ferret fulltext engine.',
           get_class($this)));
     }
 
     return $this->ferretTokens;
   }
 
   public function withFerretConstraint(
     PhabricatorFerretEngine $engine,
     array $fulltext_tokens) {
 
     if (!$this->supportsFerretEngine()) {
       throw new Exception(
         pht(
           'Query ("%s") does not support the Ferret fulltext engine.',
           get_class($this)));
     }
 
     if ($this->ferretEngine) {
       throw new Exception(
         pht(
           'Query may not have multiple fulltext constraints.'));
     }
 
     if (!$fulltext_tokens) {
       return $this;
     }
 
     $this->ferretEngine = $engine;
     $this->ferretTokens = $fulltext_tokens;
 
     $op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
 
     $default_function = $engine->getDefaultFunctionKey();
     $table_map = array();
     $idx = 1;
     foreach ($this->ferretTokens as $fulltext_token) {
       $raw_token = $fulltext_token->getToken();
 
       $function = $raw_token->getFunction();
       if ($function === null) {
         $function = $default_function;
       }
 
       $function_def = $engine->getFunctionForName($function);
 
       // NOTE: The query compiler guarantees that a query can not make a
       // field both "present" and "absent", so it's safe to just use the
       // first operator we encounter to determine whether the table is
       // optional or not.
 
       $operator = $raw_token->getOperator();
       $is_optional = ($operator === $op_absent);
 
       if (!isset($table_map[$function])) {
         $alias = 'ftfield_'.$idx++;
         $table_map[$function] = array(
           'alias' => $alias,
           'function' => $function_def,
           'optional' => $is_optional,
         );
       }
     }
 
     // Join the title field separately so we can rank results.
     $table_map['rank'] = array(
       'alias' => 'ft_rank',
       'function' => $engine->getFunctionForName('title'),
 
       // See T13345. Not every document has a title, so we want to LEFT JOIN
       // this table to avoid excluding documents with no title that match
       // the query in other fields.
       'optional' => true,
     );
 
     $this->ferretTables = $table_map;
 
     return $this;
   }
 
   protected function buildFerretSelectClause(AphrontDatabaseConnection $conn) {
     $select = array();
 
     if (!$this->supportsFerretEngine()) {
       return $select;
     }
 
     if (!$this->hasFerretOrder()) {
       // We only need to SELECT the virtual rank/relevance columns if we're
       // actually sorting the results by rank.
       return $select;
     }
 
     if (!$this->ferretEngine) {
       $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_RANK);
       $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_CREATED);
       $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_MODIFIED);
       return $select;
     }
 
     $engine = $this->ferretEngine;
     $stemmer = $engine->newStemmer();
 
     $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
     $op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
     $table_alias = 'ft_rank';
 
     $parts = array();
     foreach ($this->ferretTokens as $fulltext_token) {
       $raw_token = $fulltext_token->getToken();
       $value = $raw_token->getValue();
 
       if ($raw_token->getOperator() == $op_not) {
         // Ignore "not" terms when ranking, since they aren't useful.
         continue;
       }
 
       if ($raw_token->getOperator() == $op_sub) {
         $is_substring = true;
       } else {
         $is_substring = false;
       }
 
       if ($is_substring) {
         $parts[] = qsprintf(
           $conn,
           'IF(%T.rawCorpus LIKE %~, 2, 0)',
           $table_alias,
           $value);
         continue;
       }
 
       if ($raw_token->isQuoted()) {
         $is_quoted = true;
         $is_stemmed = false;
       } else {
         $is_quoted = false;
         $is_stemmed = true;
       }
 
       $term_constraints = array();
 
       $term_value = $engine->newTermsCorpus($value);
 
       $parts[] = qsprintf(
         $conn,
         'IF(%T.termCorpus LIKE %~, 2, 0)',
         $table_alias,
         $term_value);
 
       if ($is_stemmed) {
         $stem_value = $stemmer->stemToken($value);
         $stem_value = $engine->newTermsCorpus($stem_value);
 
         $parts[] = qsprintf(
           $conn,
           'IF(%T.normalCorpus LIKE %~, 1, 0)',
           $table_alias,
           $stem_value);
       }
     }
 
     $parts[] = qsprintf($conn, '%d', 0);
 
     $sum = array_shift($parts);
     foreach ($parts as $part) {
       $sum = qsprintf(
         $conn,
         '%Q + %Q',
         $sum,
         $part);
     }
 
     $select[] = qsprintf(
       $conn,
       '%Q AS %T',
       $sum,
       self::FULLTEXT_RANK);
 
     // See D20297. We select these as real columns in the result set so that
     // constructions like this will work:
     //
     //   ((SELECT ...) UNION (SELECT ...)) ORDER BY ...
     //
     // If the columns aren't part of the result set, the final "ORDER BY" can
     // not act on them.
 
     $select[] = qsprintf(
       $conn,
       'ft_doc.epochCreated AS %T',
       self::FULLTEXT_CREATED);
 
     $select[] = qsprintf(
       $conn,
       'ft_doc.epochModified AS %T',
       self::FULLTEXT_MODIFIED);
 
     return $select;
   }
 
   protected function buildFerretJoinClause(AphrontDatabaseConnection $conn) {
     if (!$this->ferretEngine) {
       return array();
     }
 
     $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
     $op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
     $op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
     $op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;
 
     $engine = $this->ferretEngine;
     $stemmer = $engine->newStemmer();
 
     $ngram_table = $engine->getNgramsTableName();
     $ngram_engine = $this->getNgramEngine();
 
     $flat = array();
     foreach ($this->ferretTokens as $fulltext_token) {
       $raw_token = $fulltext_token->getToken();
 
       $operator = $raw_token->getOperator();
 
       // If this is a negated term like "-pomegranate", don't join the ngram
       // table since we aren't looking for documents with this term. (We could
       // LEFT JOIN the table and require a NULL row, but this is probably more
       // trouble than it's worth.)
       if ($operator === $op_not) {
         continue;
       }
 
       // Neither the "present" or "absent" operators benefit from joining
       // the ngram table.
       if ($operator === $op_absent || $operator === $op_present) {
         continue;
       }
 
       $value = $raw_token->getValue();
 
       $length = count(phutil_utf8v($value));
 
       if ($raw_token->getOperator() == $op_sub) {
         $is_substring = true;
       } else {
         $is_substring = false;
       }
 
       // If the user specified a substring query for a substring which is
       // shorter than the ngram length, we can't use the ngram index, so
       // don't do a join. We'll fall back to just doing LIKE on the full
       // corpus.
       if ($is_substring) {
         if ($length < 3) {
           continue;
         }
       }
 
       if ($raw_token->isQuoted()) {
         $is_stemmed = false;
       } else {
         $is_stemmed = true;
       }
 
       if ($is_substring) {
         $ngrams = $ngram_engine->getSubstringNgramsFromString($value);
       } else {
         $terms_value = $engine->newTermsCorpus($value);
         $ngrams = $ngram_engine->getTermNgramsFromString($terms_value);
 
         // If this is a stemmed term, only look for ngrams present in both the
         // unstemmed and stemmed variations.
         if ($is_stemmed) {
           // Trim the boundary space characters so the stemmer recognizes this
           // is (or, at least, may be) a normal word and activates.
           $terms_value = trim($terms_value, ' ');
           $stem_value = $stemmer->stemToken($terms_value);
           $stem_ngrams = $ngram_engine->getTermNgramsFromString($stem_value);
           $ngrams = array_intersect($ngrams, $stem_ngrams);
         }
       }
 
       foreach ($ngrams as $ngram) {
         $flat[] = array(
           'table' => $ngram_table,
           'ngram' => $ngram,
         );
       }
     }
 
     // Remove common ngrams, like "the", which occur too frequently in
     // documents to be useful in constraining the query. The best ngrams
     // are obscure sequences which occur in very few documents.
 
     if ($flat) {
       $common_ngrams = queryfx_all(
         $conn,
         'SELECT ngram FROM %T WHERE ngram IN (%Ls)',
         $engine->getCommonNgramsTableName(),
         ipull($flat, 'ngram'));
       $common_ngrams = ipull($common_ngrams, 'ngram', 'ngram');
 
       foreach ($flat as $key => $spec) {
         $ngram = $spec['ngram'];
         if (isset($common_ngrams[$ngram])) {
           unset($flat[$key]);
           continue;
         }
 
         // NOTE: MySQL discards trailing whitespace in CHAR(X) columns.
         $trim_ngram = rtrim($ngram, ' ');
         if (isset($common_ngrams[$trim_ngram])) {
           unset($flat[$key]);
           continue;
         }
       }
     }
 
     // MySQL only allows us to join a maximum of 61 tables per query. Each
     // ngram is going to cost us a join toward that limit, so if the user
     // specified a very long query string, just pick 16 of the ngrams
     // at random.
     if (count($flat) > 16) {
       shuffle($flat);
       $flat = array_slice($flat, 0, 16);
     }
 
     $alias = $this->getPrimaryTableAlias();
     if ($alias) {
       $phid_column = qsprintf($conn, '%T.%T', $alias, 'phid');
     } else {
       $phid_column = qsprintf($conn, '%T', 'phid');
     }
 
     $document_table = $engine->getDocumentTableName();
     $field_table = $engine->getFieldTableName();
 
     $joins = array();
     $joins[] = qsprintf(
       $conn,
       'JOIN %T ft_doc ON ft_doc.objectPHID = %Q',
       $document_table,
       $phid_column);
 
     $idx = 1;
     foreach ($flat as $spec) {
       $table = $spec['table'];
       $ngram = $spec['ngram'];
 
       $alias = 'ftngram_'.$idx++;
 
       $joins[] = qsprintf(
         $conn,
         'JOIN %T %T ON %T.documentID = ft_doc.id AND %T.ngram = %s',
         $table,
         $alias,
         $alias,
         $alias,
         $ngram);
     }
 
     $object = $this->newResultObject();
     if (!$object) {
       throw new Exception(
         pht(
           'Query class ("%s") must define "newResultObject()" to use '.
           'Ferret constraints.',
           get_class($this)));
     }
 
     // See T13511. If we have a fulltext query which uses valid field
     // functions, but at least one of the functions applies to a field which
     // the object can never have, the query can never match anything. Detect
     // this and return an empty result set.
 
     // (Even if the query is "field is absent" or "field does not contain
     // such-and-such", the interpretation is that these constraints are
     // not meaningful when applied to an object which can never have the
     // field.)
 
     $functions = ipull($this->ferretTables, 'function');
     $functions = mpull($functions, null, 'getFerretFunctionName');
     foreach ($functions as $function) {
       if (!$function->supportsObject($object)) {
         throw new PhabricatorEmptyQueryException(
           pht(
             'This query uses a fulltext function which this document '.
             'type does not support.'));
       }
     }
 
     foreach ($this->ferretTables as $table) {
       $alias = $table['alias'];
 
       if (empty($table['optional'])) {
         $join_type = qsprintf($conn, 'JOIN');
       } else {
         $join_type = qsprintf($conn, 'LEFT JOIN');
       }
 
       $joins[] = qsprintf(
         $conn,
         '%Q %T %T ON ft_doc.id = %T.documentID
           AND %T.fieldKey = %s',
         $join_type,
         $field_table,
         $alias,
         $alias,
         $alias,
         $table['function']->getFerretFieldKey());
     }
 
     return $joins;
   }
 
   protected function buildFerretWhereClause(AphrontDatabaseConnection $conn) {
     if (!$this->ferretEngine) {
       return array();
     }
 
     $engine = $this->ferretEngine;
     $stemmer = $engine->newStemmer();
     $table_map = $this->ferretTables;
 
     $op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
     $op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
     $op_exact = PhutilSearchQueryCompiler::OPERATOR_EXACT;
     $op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
     $op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;
 
     $where = array();
     $default_function = $engine->getDefaultFunctionKey();
     foreach ($this->ferretTokens as $fulltext_token) {
       $raw_token = $fulltext_token->getToken();
       $value = $raw_token->getValue();
 
       $function = $raw_token->getFunction();
       if ($function === null) {
         $function = $default_function;
       }
 
       $operator = $raw_token->getOperator();
 
       $table_alias = $table_map[$function]['alias'];
 
       // If this is a "field is present" operator, we've already implicitly
       // guaranteed this by JOINing the table. We don't need to do any
       // more work.
       $is_present = ($operator === $op_present);
       if ($is_present) {
         continue;
       }
 
       // If this is a "field is absent" operator, we just want documents
       // which failed to match to a row when we LEFT JOINed the table. This
       // means there's no index for the field.
       $is_absent = ($operator === $op_absent);
       if ($is_absent) {
         $where[] = qsprintf(
           $conn,
           '(%T.rawCorpus IS NULL)',
           $table_alias);
         continue;
       }
 
       $is_not = ($operator === $op_not);
 
       if ($operator == $op_sub) {
         $is_substring = true;
       } else {
         $is_substring = false;
       }
 
       // If we're doing exact search, just test the raw corpus.
       $is_exact = ($operator === $op_exact);
       if ($is_exact) {
         if ($is_not) {
           $where[] = qsprintf(
             $conn,
             '(%T.rawCorpus != %s)',
             $table_alias,
             $value);
         } else {
           $where[] = qsprintf(
             $conn,
             '(%T.rawCorpus = %s)',
             $table_alias,
             $value);
         }
         continue;
       }
 
       // If we're doing substring search, we just match against the raw corpus
       // and we're done.
       if ($is_substring) {
         if ($is_not) {
           $where[] = qsprintf(
             $conn,
             '(%T.rawCorpus NOT LIKE %~)',
             $table_alias,
             $value);
         } else {
           $where[] = qsprintf(
             $conn,
             '(%T.rawCorpus LIKE %~)',
             $table_alias,
             $value);
         }
         continue;
       }
 
       // Otherwise, we need to match against the term corpus and the normal
       // corpus, so that searching for "raw" does not find "strawberry".
       if ($raw_token->isQuoted()) {
         $is_quoted = true;
         $is_stemmed = false;
       } else {
         $is_quoted = false;
         $is_stemmed = true;
       }
 
       // Never stem negated queries, since this can exclude results users
       // did not mean to exclude and generally confuse things.
       if ($is_not) {
         $is_stemmed = false;
       }
 
       $term_constraints = array();
 
       $term_value = $engine->newTermsCorpus($value);
       if ($is_not) {
         $term_constraints[] = qsprintf(
           $conn,
           '(%T.termCorpus NOT LIKE %~)',
           $table_alias,
           $term_value);
       } else {
         $term_constraints[] = qsprintf(
           $conn,
           '(%T.termCorpus LIKE %~)',
           $table_alias,
           $term_value);
       }
 
       if ($is_stemmed) {
         $stem_value = $stemmer->stemToken($value);
         $stem_value = $engine->newTermsCorpus($stem_value);
 
         $term_constraints[] = qsprintf(
           $conn,
           '(%T.normalCorpus LIKE %~)',
           $table_alias,
           $stem_value);
       }
 
       if ($is_not) {
         $where[] = qsprintf(
           $conn,
           '%LA',
           $term_constraints);
       } else if ($is_quoted) {
         $where[] = qsprintf(
           $conn,
           '(%T.rawCorpus LIKE %~ AND %LO)',
           $table_alias,
           $value,
           $term_constraints);
       } else {
         $where[] = qsprintf(
           $conn,
           '%LO',
           $term_constraints);
       }
     }
 
     if ($this->ferretQuery) {
       $query = $this->ferretQuery;
 
       $author_phids = $query->getParameter('authorPHIDs');
       if ($author_phids) {
         $where[] = qsprintf(
           $conn,
           'ft_doc.authorPHID IN (%Ls)',
           $author_phids);
       }
 
       $with_unowned = $query->getParameter('withUnowned');
       $with_any = $query->getParameter('withAnyOwner');
 
       if ($with_any && $with_unowned) {
         throw new PhabricatorEmptyQueryException(
           pht(
             'This query matches only unowned documents owned by anyone, '.
             'which is impossible.'));
       }
 
       $owner_phids = $query->getParameter('ownerPHIDs');
       if ($owner_phids && !$with_any) {
         if ($with_unowned) {
           $where[] = qsprintf(
             $conn,
             'ft_doc.ownerPHID IN (%Ls) OR ft_doc.ownerPHID IS NULL',
             $owner_phids);
         } else {
           $where[] = qsprintf(
             $conn,
             'ft_doc.ownerPHID IN (%Ls)',
             $owner_phids);
         }
       } else if ($with_unowned) {
         $where[] = qsprintf(
           $conn,
           'ft_doc.ownerPHID IS NULL');
       }
 
       if ($with_any) {
         $where[] = qsprintf(
           $conn,
           'ft_doc.ownerPHID IS NOT NULL');
       }
 
       $rel_open = PhabricatorSearchRelationship::RELATIONSHIP_OPEN;
 
       $statuses = $query->getParameter('statuses');
       $is_closed = null;
       if ($statuses) {
         $statuses = array_fuse($statuses);
         if (count($statuses) == 1) {
           if (isset($statuses[$rel_open])) {
             $is_closed = 0;
           } else {
             $is_closed = 1;
           }
         }
       }
 
       if ($is_closed !== null) {
         $where[] = qsprintf(
           $conn,
           'ft_doc.isClosed = %d',
           $is_closed);
       }
     }
 
     return $where;
   }
 
   protected function shouldGroupFerretResultRows() {
     return (bool)$this->ferretTokens;
   }
 
 
 /* -(  Ngrams  )------------------------------------------------------------- */
 
 
   protected function withNgramsConstraint(
     PhabricatorSearchNgrams $index,
     $value) {
 
     if (phutil_nonempty_string($value)) {
       $this->ngrams[] = array(
         'index' => $index,
         'value' => $value,
         'length' => count(phutil_utf8v($value)),
       );
     }
 
     return $this;
   }
 
 
   protected function buildNgramsJoinClause(AphrontDatabaseConnection $conn) {
     $ngram_engine = $this->getNgramEngine();
 
     $flat = array();
     foreach ($this->ngrams as $spec) {
       $length = $spec['length'];
 
       if ($length < 3) {
         continue;
       }
 
       $index = $spec['index'];
       $value = $spec['value'];
 
       $ngrams = $ngram_engine->getSubstringNgramsFromString($value);
 
       foreach ($ngrams as $ngram) {
         $flat[] = array(
           'table' => $index->getTableName(),
           'ngram' => $ngram,
         );
       }
     }
 
     if (!$flat) {
       return array();
     }
 
     // MySQL only allows us to join a maximum of 61 tables per query. Each
     // ngram is going to cost us a join toward that limit, so if the user
     // specified a very long query string, just pick 16 of the ngrams
     // at random.
     if (count($flat) > 16) {
       shuffle($flat);
       $flat = array_slice($flat, 0, 16);
     }
 
     $alias = $this->getPrimaryTableAlias();
     if ($alias) {
       $id_column = qsprintf($conn, '%T.%T', $alias, 'id');
     } else {
       $id_column = qsprintf($conn, '%T', 'id');
     }
 
     $idx = 1;
     $joins = array();
     foreach ($flat as $spec) {
       $table = $spec['table'];
       $ngram = $spec['ngram'];
 
       $alias = 'ngm'.$idx++;
 
       $joins[] = qsprintf(
         $conn,
         'JOIN %T %T ON %T.objectID = %Q AND %T.ngram = %s',
         $table,
         $alias,
         $alias,
         $id_column,
         $alias,
         $ngram);
     }
 
     return $joins;
   }
 
 
   protected function buildNgramsWhereClause(AphrontDatabaseConnection $conn) {
     $where = array();
 
     $ngram_engine = $this->getNgramEngine();
 
     foreach ($this->ngrams as $ngram) {
       $index = $ngram['index'];
       $value = $ngram['value'];
 
       $column = $index->getColumnName();
       $alias = $this->getPrimaryTableAlias();
       if ($alias) {
         $column = qsprintf($conn, '%T.%T', $alias, $column);
       } else {
         $column = qsprintf($conn, '%T', $column);
       }
 
       $tokens = $ngram_engine->tokenizeNgramString($value);
 
       foreach ($tokens as $token) {
         $where[] = qsprintf(
           $conn,
           '%Q LIKE %~',
           $column,
           $token);
       }
     }
 
     return $where;
   }
 
 
   protected function shouldGroupNgramResultRows() {
     return (bool)$this->ngrams;
   }
 
   private function getNgramEngine() {
     if (!$this->ngramEngine) {
       $this->ngramEngine = new PhabricatorSearchNgramEngine();
     }
 
     return $this->ngramEngine;
   }
 
 
 /* -(  Edge Logic  )--------------------------------------------------------- */
 
 
   /**
    * Convenience method for specifying edge logic constraints with a list of
    * PHIDs.
    *
-   * @param const Edge constant.
-   * @param const Constraint operator.
-   * @param list<phid> List of PHIDs.
+   * @param const $edge_type Edge constant.
+   * @param const $operator Constraint operator.
+   * @param list<phid> $phids List of PHIDs.
    * @return this
    * @task edgelogic
    */
   public function withEdgeLogicPHIDs($edge_type, $operator, array $phids) {
     $constraints = array();
     foreach ($phids as $phid) {
       $constraints[] = new PhabricatorQueryConstraint($operator, $phid);
     }
 
     return $this->withEdgeLogicConstraints($edge_type, $constraints);
   }
 
 
   /**
    * @return this
    * @task edgelogic
    */
   public function withEdgeLogicConstraints($edge_type, array $constraints) {
     assert_instances_of($constraints, 'PhabricatorQueryConstraint');
 
     $constraints = mgroup($constraints, 'getOperator');
     foreach ($constraints as $operator => $list) {
       foreach ($list as $item) {
         $this->edgeLogicConstraints[$edge_type][$operator][] = $item;
       }
     }
 
     $this->edgeLogicConstraintsAreValid = false;
 
     return $this;
   }
 
 
   /**
    * @task edgelogic
    */
   public function buildEdgeLogicSelectClause(AphrontDatabaseConnection $conn) {
     $select = array();
 
     $this->validateEdgeLogicConstraints();
 
     foreach ($this->edgeLogicConstraints as $type => $constraints) {
       foreach ($constraints as $operator => $list) {
         $alias = $this->getEdgeLogicTableAlias($operator, $type);
         switch ($operator) {
           case PhabricatorQueryConstraint::OPERATOR_AND:
             if (count($list) > 1) {
               $select[] = qsprintf(
                 $conn,
                 'COUNT(DISTINCT(%T.dst)) %T',
                 $alias,
                 $this->buildEdgeLogicTableAliasCount($alias));
             }
             break;
           case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
             // This is tricky. We have a query which specifies multiple
             // projects, each of which may have an arbitrarily large number
             // of descendants.
 
             // Suppose the projects are "Engineering" and "Operations", and
             // "Engineering" has subprojects X, Y and Z.
 
             // We first use `FIELD(dst, X, Y, Z)` to produce a 0 if a row
             // is not part of Engineering at all, or some number other than
             // 0 if it is.
 
             // Then we use `IF(..., idx, NULL)` to convert the 0 to a NULL and
             // any other value to an index (say, 1) for the ancestor.
 
             // We build these up for every ancestor, then use `COALESCE(...)`
             // to select the non-null one, giving us an ancestor which this
             // row is a member of.
 
             // From there, we use `COUNT(DISTINCT(...))` to make sure that
             // each result row is a member of all ancestors.
             if (count($list) > 1) {
               $idx = 1;
               $parts = array();
               foreach ($list as $constraint) {
                 $parts[] = qsprintf(
                   $conn,
                   'IF(FIELD(%T.dst, %Ls) != 0, %d, NULL)',
                   $alias,
                   (array)$constraint->getValue(),
                   $idx++);
               }
               $parts = qsprintf($conn, '%LQ', $parts);
 
               $select[] = qsprintf(
                 $conn,
                 'COUNT(DISTINCT(COALESCE(%Q))) %T',
                 $parts,
                 $this->buildEdgeLogicTableAliasAncestor($alias));
             }
             break;
           default:
             break;
         }
       }
     }
 
     return $select;
   }
 
 
   /**
    * @task edgelogic
    */
   public function buildEdgeLogicJoinClause(AphrontDatabaseConnection $conn) {
     $edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE;
     $phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
 
     $joins = array();
     foreach ($this->edgeLogicConstraints as $type => $constraints) {
 
       $op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
       $has_null = isset($constraints[$op_null]);
 
       // If we're going to process an only() operator, build a list of the
       // acceptable set of PHIDs first. We'll only match results which have
       // no edges to any other PHIDs.
       $all_phids = array();
       if (isset($constraints[PhabricatorQueryConstraint::OPERATOR_ONLY])) {
         foreach ($constraints as $operator => $list) {
           switch ($operator) {
             case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
             case PhabricatorQueryConstraint::OPERATOR_AND:
             case PhabricatorQueryConstraint::OPERATOR_OR:
               foreach ($list as $constraint) {
                 $value = (array)$constraint->getValue();
                 foreach ($value as $v) {
                   $all_phids[$v] = $v;
                 }
               }
               break;
           }
         }
       }
 
       foreach ($constraints as $operator => $list) {
         $alias = $this->getEdgeLogicTableAlias($operator, $type);
 
         $phids = array();
         foreach ($list as $constraint) {
           $value = (array)$constraint->getValue();
           foreach ($value as $v) {
             $phids[$v] = $v;
           }
         }
         $phids = array_keys($phids);
 
         switch ($operator) {
           case PhabricatorQueryConstraint::OPERATOR_NOT:
             $joins[] = qsprintf(
               $conn,
               'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
                 AND %T.dst IN (%Ls)',
               $edge_table,
               $alias,
               $phid_column,
               $alias,
               $alias,
               $type,
               $alias,
               $phids);
             break;
           case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
           case PhabricatorQueryConstraint::OPERATOR_AND:
           case PhabricatorQueryConstraint::OPERATOR_OR:
             // If we're including results with no matches, we have to degrade
             // this to a LEFT join. We'll use WHERE to select matching rows
             // later.
             if ($has_null) {
               $join_type = qsprintf($conn, 'LEFT');
             } else {
               $join_type = qsprintf($conn, '');
             }
 
             $joins[] = qsprintf(
               $conn,
               '%Q JOIN %T %T ON %Q = %T.src AND %T.type = %d
                 AND %T.dst IN (%Ls)',
               $join_type,
               $edge_table,
               $alias,
               $phid_column,
               $alias,
               $alias,
               $type,
               $alias,
               $phids);
             break;
           case PhabricatorQueryConstraint::OPERATOR_NULL:
             $joins[] = qsprintf(
               $conn,
               'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d',
               $edge_table,
               $alias,
               $phid_column,
               $alias,
               $alias,
               $type);
             break;
           case PhabricatorQueryConstraint::OPERATOR_ONLY:
             $joins[] = qsprintf(
               $conn,
               'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
                 AND %T.dst NOT IN (%Ls)',
               $edge_table,
               $alias,
               $phid_column,
               $alias,
               $alias,
               $type,
               $alias,
               $all_phids);
             break;
         }
       }
     }
 
     return $joins;
   }
 
 
   /**
    * @task edgelogic
    */
   public function buildEdgeLogicWhereClause(AphrontDatabaseConnection $conn) {
     $where = array();
 
     foreach ($this->edgeLogicConstraints as $type => $constraints) {
 
       $full = array();
       $null = array();
 
       $op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
       $has_null = isset($constraints[$op_null]);
 
       foreach ($constraints as $operator => $list) {
         $alias = $this->getEdgeLogicTableAlias($operator, $type);
         switch ($operator) {
           case PhabricatorQueryConstraint::OPERATOR_NOT:
           case PhabricatorQueryConstraint::OPERATOR_ONLY:
             $full[] = qsprintf(
               $conn,
               '%T.dst IS NULL',
               $alias);
             break;
           case PhabricatorQueryConstraint::OPERATOR_AND:
           case PhabricatorQueryConstraint::OPERATOR_OR:
             if ($has_null) {
               $full[] = qsprintf(
                 $conn,
                 '%T.dst IS NOT NULL',
                 $alias);
             }
             break;
           case PhabricatorQueryConstraint::OPERATOR_NULL:
             $null[] = qsprintf(
               $conn,
               '%T.dst IS NULL',
               $alias);
             break;
         }
       }
 
       if ($full && $null) {
         $where[] = qsprintf($conn, '(%LA OR %LA)', $full, $null);
       } else if ($full) {
         foreach ($full as $condition) {
           $where[] = $condition;
         }
       } else if ($null) {
         foreach ($null as $condition) {
           $where[] = $condition;
         }
       }
     }
 
     return $where;
   }
 
 
   /**
    * @task edgelogic
    */
   public function buildEdgeLogicHavingClause(AphrontDatabaseConnection $conn) {
     $having = array();
 
     foreach ($this->edgeLogicConstraints as $type => $constraints) {
       foreach ($constraints as $operator => $list) {
         $alias = $this->getEdgeLogicTableAlias($operator, $type);
         switch ($operator) {
           case PhabricatorQueryConstraint::OPERATOR_AND:
             if (count($list) > 1) {
               $having[] = qsprintf(
                 $conn,
                 '%T = %d',
                 $this->buildEdgeLogicTableAliasCount($alias),
                 count($list));
             }
             break;
           case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
             if (count($list) > 1) {
               $having[] = qsprintf(
                 $conn,
                 '%T = %d',
                 $this->buildEdgeLogicTableAliasAncestor($alias),
                 count($list));
             }
             break;
         }
       }
     }
 
     return $having;
   }
 
 
   /**
    * @task edgelogic
    */
   public function shouldGroupEdgeLogicResultRows() {
     foreach ($this->edgeLogicConstraints as $type => $constraints) {
       foreach ($constraints as $operator => $list) {
         switch ($operator) {
           case PhabricatorQueryConstraint::OPERATOR_NOT:
           case PhabricatorQueryConstraint::OPERATOR_AND:
           case PhabricatorQueryConstraint::OPERATOR_OR:
             if (count($list) > 1) {
               return true;
             }
             break;
           case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
             // NOTE: We must always group query results rows when using an
             // "ANCESTOR" operator because a single task may be related to
             // two different descendants of a particular ancestor. For
             // discussion, see T12753.
             return true;
           case PhabricatorQueryConstraint::OPERATOR_NULL:
           case PhabricatorQueryConstraint::OPERATOR_ONLY:
             return true;
         }
       }
     }
 
     return false;
   }
 
 
   /**
    * @task edgelogic
    */
   private function getEdgeLogicTableAlias($operator, $type) {
     return 'edgelogic_'.$operator.'_'.$type;
   }
 
 
   /**
    * @task edgelogic
    */
   private function buildEdgeLogicTableAliasCount($alias) {
     return $alias.'_count';
   }
 
   /**
    * @task edgelogic
    */
   private function buildEdgeLogicTableAliasAncestor($alias) {
     return $alias.'_ancestor';
   }
 
 
   /**
    * Select certain edge logic constraint values.
    *
    * @task edgelogic
    */
   protected function getEdgeLogicValues(
     array $edge_types,
     array $operators) {
 
     $values = array();
 
     $constraint_lists = $this->edgeLogicConstraints;
     if ($edge_types) {
       $constraint_lists = array_select_keys($constraint_lists, $edge_types);
     }
 
     foreach ($constraint_lists as $type => $constraints) {
       if ($operators) {
         $constraints = array_select_keys($constraints, $operators);
       }
       foreach ($constraints as $operator => $list) {
         foreach ($list as $constraint) {
           $value = (array)$constraint->getValue();
           foreach ($value as $v) {
             $values[] = $v;
           }
         }
       }
     }
 
     return $values;
   }
 
 
   /**
    * Validate edge logic constraints for the query.
    *
    * @return this
    * @task edgelogic
    */
   private function validateEdgeLogicConstraints() {
     if ($this->edgeLogicConstraintsAreValid) {
       return $this;
     }
 
     foreach ($this->edgeLogicConstraints as $type => $constraints) {
       foreach ($constraints as $operator => $list) {
         switch ($operator) {
           case PhabricatorQueryConstraint::OPERATOR_EMPTY:
             throw new PhabricatorEmptyQueryException(
               pht('This query specifies an empty constraint.'));
         }
       }
     }
 
     // This should probably be more modular, eventually, but we only do
     // project-based edge logic today.
 
     $project_phids = $this->getEdgeLogicValues(
       array(
         PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
       ),
       array(
         PhabricatorQueryConstraint::OPERATOR_AND,
         PhabricatorQueryConstraint::OPERATOR_OR,
         PhabricatorQueryConstraint::OPERATOR_NOT,
         PhabricatorQueryConstraint::OPERATOR_ANCESTOR,
       ));
     if ($project_phids) {
       $projects = id(new PhabricatorProjectQuery())
         ->setViewer($this->getViewer())
         ->setParentQuery($this)
         ->withPHIDs($project_phids)
         ->execute();
       $projects = mpull($projects, null, 'getPHID');
       foreach ($project_phids as $phid) {
         if (empty($projects[$phid])) {
           throw new PhabricatorEmptyQueryException(
             pht(
               'This query is constrained by a project you do not have '.
               'permission to see.'));
         }
       }
     }
 
     $op_and = PhabricatorQueryConstraint::OPERATOR_AND;
     $op_or = PhabricatorQueryConstraint::OPERATOR_OR;
     $op_ancestor = PhabricatorQueryConstraint::OPERATOR_ANCESTOR;
 
     foreach ($this->edgeLogicConstraints as $type => $constraints) {
       foreach ($constraints as $operator => $list) {
         switch ($operator) {
           case PhabricatorQueryConstraint::OPERATOR_ONLY:
             if (count($list) > 1) {
               throw new PhabricatorEmptyQueryException(
                 pht(
                   'This query specifies only() more than once.'));
             }
 
             $have_and = idx($constraints, $op_and);
             $have_or = idx($constraints, $op_or);
             $have_ancestor = idx($constraints, $op_ancestor);
             if (!$have_and && !$have_or && !$have_ancestor) {
               throw new PhabricatorEmptyQueryException(
                 pht(
                   'This query specifies only(), but no other constraints '.
                   'which it can apply to.'));
             }
             break;
         }
       }
     }
 
     $this->edgeLogicConstraintsAreValid = true;
 
     return $this;
   }
 
 
 /* -(  Spaces  )------------------------------------------------------------- */
 
 
   /**
    * Constrain the query to return results from only specific Spaces.
    *
    * Pass a list of Space PHIDs, or `null` to represent the default space. Only
    * results in those Spaces will be returned.
    *
    * Queries are always constrained to include only results from spaces the
    * viewer has access to.
    *
-   * @param list<phid|null>
+   * @param list<phid|null> $space_phids
    * @task spaces
    */
   public function withSpacePHIDs(array $space_phids) {
     $object = $this->newResultObject();
 
     if (!$object) {
       throw new Exception(
         pht(
           'This query (of class "%s") does not implement newResultObject(), '.
           'but must implement this method to enable support for Spaces.',
           get_class($this)));
     }
 
     if (!($object instanceof PhabricatorSpacesInterface)) {
       throw new Exception(
         pht(
           'This query (of class "%s") returned an object of class "%s" from '.
           'getNewResultObject(), but it does not implement the required '.
           'interface ("%s"). Objects must implement this interface to enable '.
           'Spaces support.',
           get_class($this),
           get_class($object),
           'PhabricatorSpacesInterface'));
     }
 
     $this->spacePHIDs = $space_phids;
 
     return $this;
   }
 
   public function withSpaceIsArchived($archived) {
     $this->spaceIsArchived = $archived;
     return $this;
   }
 
 
   /**
    * Constrain the query to include only results in valid Spaces.
    *
    * This method builds part of a WHERE clause which considers the spaces the
    * viewer has access to see with any explicit constraint on spaces added by
    * @{method:withSpacePHIDs}.
    *
-   * @param AphrontDatabaseConnection Database connection.
+   * @param AphrontDatabaseConnection $conn Database connection.
    * @return string Part of a WHERE clause.
    * @task spaces
    */
   private function buildSpacesWhereClause(AphrontDatabaseConnection $conn) {
     $object = $this->newResultObject();
     if (!$object) {
       return null;
     }
 
     if (!($object instanceof PhabricatorSpacesInterface)) {
       return null;
     }
 
     $viewer = $this->getViewer();
 
     // If we have an omnipotent viewer and no formal space constraints, don't
     // emit a clause. This primarily enables older migrations to run cleanly,
     // without fataling because they try to match a `spacePHID` column which
     // does not exist yet. See T8743, T8746.
     if ($viewer->isOmnipotent()) {
       if ($this->spaceIsArchived === null && $this->spacePHIDs === null) {
         return null;
       }
     }
 
     // See T13240. If this query raises policy exceptions, don't filter objects
     // in the MySQL layer. We want them to reach the application layer so we
     // can reject them and raise an exception.
     if ($this->shouldRaisePolicyExceptions()) {
       return null;
     }
 
     $space_phids = array();
     $include_null = false;
 
     $all = PhabricatorSpacesNamespaceQuery::getAllSpaces();
     if (!$all) {
       // If there are no spaces at all, implicitly give the viewer access to
       // the default space.
       $include_null = true;
     } else {
       // Otherwise, give them access to the spaces they have permission to
       // see.
       $viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces(
         $viewer);
       foreach ($viewer_spaces as $viewer_space) {
         if ($this->spaceIsArchived !== null) {
           if ($viewer_space->getIsArchived() != $this->spaceIsArchived) {
             continue;
           }
         }
         $phid = $viewer_space->getPHID();
         $space_phids[$phid] = $phid;
         if ($viewer_space->getIsDefaultNamespace()) {
           $include_null = true;
         }
       }
     }
 
     // If we have additional explicit constraints, evaluate them now.
     if ($this->spacePHIDs !== null) {
       $explicit = array();
       $explicit_null = false;
       foreach ($this->spacePHIDs as $phid) {
         if ($phid === null) {
           $space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
         } else {
           $space = idx($all, $phid);
         }
 
         if ($space) {
           $phid = $space->getPHID();
           $explicit[$phid] = $phid;
           if ($space->getIsDefaultNamespace()) {
             $explicit_null = true;
           }
         }
       }
 
       // If the viewer can see the default space but it isn't on the explicit
       // list of spaces to query, don't match it.
       if ($include_null && !$explicit_null) {
         $include_null = false;
       }
 
       // Include only the spaces common to the viewer and the constraints.
       $space_phids = array_intersect_key($space_phids, $explicit);
     }
 
     if (!$space_phids && !$include_null) {
       if ($this->spacePHIDs === null) {
         throw new PhabricatorEmptyQueryException(
           pht('You do not have access to any spaces.'));
       } else {
         throw new PhabricatorEmptyQueryException(
           pht(
             'You do not have access to any of the spaces this query '.
             'is constrained to.'));
       }
     }
 
     $alias = $this->getPrimaryTableAlias();
     if ($alias) {
       $col = qsprintf($conn, '%T.spacePHID', $alias);
     } else {
       $col = qsprintf($conn, 'spacePHID');
     }
 
     if ($space_phids && $include_null) {
       return qsprintf(
         $conn,
         '(%Q IN (%Ls) OR %Q IS NULL)',
         $col,
         $space_phids,
         $col);
     } else if ($space_phids) {
       return qsprintf(
         $conn,
         '%Q IN (%Ls)',
         $col,
         $space_phids);
     } else {
       return qsprintf(
         $conn,
         '%Q IS NULL',
         $col);
     }
   }
 
   private function hasFerretOrder() {
     $vector = $this->getOrderVector();
 
     if ($vector->containsKey('rank')) {
       return true;
     }
 
     if ($vector->containsKey('fulltext-created')) {
       return true;
     }
 
     if ($vector->containsKey('fulltext-modified')) {
       return true;
     }
 
     return false;
   }
 
 }
diff --git a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php
index c43edaefcb..1faf45d63d 100644
--- a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php
+++ b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php
@@ -1,784 +1,784 @@
 <?php
 
 /**
  * A @{class:PhabricatorQuery} which filters results according to visibility
  * policies for the querying user. Broadly, this class allows you to implement
  * a query that returns only objects the user is allowed to see.
  *
  *   $results = id(new ExampleQuery())
  *     ->setViewer($user)
  *     ->withConstraint($example)
  *     ->execute();
  *
  * Normally, you should extend @{class:PhabricatorCursorPagedPolicyAwareQuery},
  * not this class. @{class:PhabricatorCursorPagedPolicyAwareQuery} provides a
  * more practical interface for building usable queries against most object
  * types.
  *
  * NOTE: Although this class extends @{class:PhabricatorOffsetPagedQuery},
  * offset paging with policy filtering is not efficient. All results must be
  * loaded into the application and filtered here: skipping `N` rows via offset
  * is an `O(N)` operation with a large constant. Prefer cursor-based paging
  * with @{class:PhabricatorCursorPagedPolicyAwareQuery}, which can filter far
  * more efficiently in MySQL.
  *
  * @task config     Query Configuration
  * @task exec       Executing Queries
  * @task policyimpl Policy Query Implementation
  */
 abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery {
 
   private $viewer;
   private $parentQuery;
   private $rawResultLimit;
   private $capabilities;
   private $workspace = array();
   private $inFlightPHIDs = array();
   private $policyFilteredPHIDs = array();
 
   /**
    * Should we continue or throw an exception when a query result is filtered
    * by policy rules?
    *
    * Values are `true` (raise exceptions), `false` (do not raise exceptions)
    * and `null` (inherit from parent query, with no exceptions by default).
    */
   private $raisePolicyExceptions;
   private $isOverheated;
   private $returnPartialResultsOnOverheat;
   private $disableOverheating;
 
 
 /* -(  Query Configuration  )------------------------------------------------ */
 
 
   /**
    * Set the viewer who is executing the query. Results will be filtered
    * according to the viewer's capabilities. You must set a viewer to execute
    * a policy query.
    *
-   * @param PhabricatorUser The viewing user.
+   * @param PhabricatorUser $viewer The viewing user.
    * @return this
    * @task config
    */
   final public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
 
   /**
    * Get the query's viewer.
    *
    * @return PhabricatorUser The viewing user.
    * @task config
    */
   final public function getViewer() {
     return $this->viewer;
   }
 
 
   /**
    * Set the parent query of this query. This is useful for nested queries so
    * that configuration like whether or not to raise policy exceptions is
    * seamlessly passed along to child queries.
    *
    * @return this
    * @task config
    */
   final public function setParentQuery(PhabricatorPolicyAwareQuery $query) {
     $this->parentQuery = $query;
     return $this;
   }
 
 
   /**
    * Get the parent query. See @{method:setParentQuery} for discussion.
    *
    * @return PhabricatorPolicyAwareQuery The parent query.
    * @task config
    */
   final public function getParentQuery() {
     return $this->parentQuery;
   }
 
 
   /**
    * Hook to configure whether this query should raise policy exceptions.
    *
    * @return this
    * @task config
    */
   final public function setRaisePolicyExceptions($bool) {
     $this->raisePolicyExceptions = $bool;
     return $this;
   }
 
 
   /**
    * @return bool
    * @task config
    */
   final public function shouldRaisePolicyExceptions() {
     return (bool)$this->raisePolicyExceptions;
   }
 
 
   /**
    * @task config
    */
   final public function requireCapabilities(array $capabilities) {
     $this->capabilities = $capabilities;
     return $this;
   }
 
   final public function setReturnPartialResultsOnOverheat($bool) {
     $this->returnPartialResultsOnOverheat = $bool;
     return $this;
   }
 
   final public function setDisableOverheating($disable_overheating) {
     $this->disableOverheating = $disable_overheating;
     return $this;
   }
 
 
 /* -(  Query Execution  )---------------------------------------------------- */
 
 
   /**
    * Execute the query, expecting a single result. This method simplifies
    * loading objects for detail pages or edit views.
    *
    *   // Load one result by ID.
    *   $obj = id(new ExampleQuery())
    *     ->setViewer($user)
    *     ->withIDs(array($id))
    *     ->executeOne();
    *   if (!$obj) {
    *     return new Aphront404Response();
    *   }
    *
    * If zero results match the query, this method returns `null`.
    * If one result matches the query, this method returns that result.
    *
    * If two or more results match the query, this method throws an exception.
    * You should use this method only when the query constraints guarantee at
    * most one match (e.g., selecting a specific ID or PHID).
    *
    * If one result matches the query but it is caught by the policy filter (for
    * example, the user is trying to view or edit an object which exists but
    * which they do not have permission to see) a policy exception is thrown.
    *
    * @return mixed Single result, or null.
    * @task exec
    */
   final public function executeOne() {
 
     $this->setRaisePolicyExceptions(true);
     try {
       $results = $this->execute();
     } catch (Exception $ex) {
       $this->setRaisePolicyExceptions(false);
       throw $ex;
     }
 
     if (count($results) > 1) {
       throw new Exception(pht('Expected a single result!'));
     }
 
     if (!$results) {
       return null;
     }
 
     return head($results);
   }
 
 
   /**
    * Execute the query, loading all visible results.
    *
    * @return list<PhabricatorPolicyInterface> Result objects.
    * @task exec
    */
   final public function execute() {
     if (!$this->viewer) {
       throw new PhutilInvalidStateException('setViewer');
     }
 
     $parent_query = $this->getParentQuery();
     if ($parent_query && ($this->raisePolicyExceptions === null)) {
       $this->setRaisePolicyExceptions(
         $parent_query->shouldRaisePolicyExceptions());
     }
 
     $results = array();
 
     $filter = $this->getPolicyFilter();
 
     $offset = (int)$this->getOffset();
     $limit  = (int)$this->getLimit();
     $count  = 0;
 
     if ($limit) {
       $need = $offset + $limit;
     } else {
       $need = 0;
     }
 
     $this->willExecute();
 
     // If we examine and filter significantly more objects than the query
     // limit, we stop early. This prevents us from looping through a huge
     // number of records when the viewer can see few or none of them. See
     // T11773 for some discussion.
     $this->isOverheated = false;
 
     // See T13386. If we are on an old offset-based paging workflow, we need
     // to base the overheating limit on both the offset and limit.
     $overheat_limit = $need * 10;
     $total_seen = 0;
 
     do {
       if ($need) {
         $this->rawResultLimit = min($need - $count, 1024);
       } else {
         $this->rawResultLimit = 0;
       }
 
       if ($this->canViewerUseQueryApplication()) {
         try {
           $page = $this->loadPage();
         } catch (PhabricatorEmptyQueryException $ex) {
           $page = array();
         }
       } else {
         $page = array();
       }
 
       $total_seen += count($page);
 
       if ($page) {
         $maybe_visible = $this->willFilterPage($page);
         if ($maybe_visible) {
           $maybe_visible = $this->applyWillFilterPageExtensions($maybe_visible);
         }
       } else {
         $maybe_visible = array();
       }
 
       if ($this->shouldDisablePolicyFiltering()) {
         $visible = $maybe_visible;
       } else {
         $visible = $filter->apply($maybe_visible);
 
         $policy_filtered = array();
         foreach ($maybe_visible as $key => $object) {
           if (empty($visible[$key])) {
             $phid = $object->getPHID();
             if ($phid) {
               $policy_filtered[$phid] = $phid;
             }
           }
         }
         $this->addPolicyFilteredPHIDs($policy_filtered);
       }
 
       if ($visible) {
         $visible = $this->didFilterPage($visible);
       }
 
       $removed = array();
       foreach ($maybe_visible as $key => $object) {
         if (empty($visible[$key])) {
           $removed[$key] = $object;
         }
       }
 
       $this->didFilterResults($removed);
 
       // NOTE: We call "nextPage()" before checking if we've found enough
       // results because we want to build the internal cursor object even
       // if we don't need to execute another query: the internal cursor may
       // be used by a parent query that is using this query to translate an
       // external cursor into an internal cursor.
       $this->nextPage($page);
 
       foreach ($visible as $key => $result) {
         ++$count;
 
         // If we have an offset, we just ignore that many results and start
         // storing them only once we've hit the offset. This reduces memory
         // requirements for large offsets, compared to storing them all and
         // slicing them away later.
         if ($count > $offset) {
           $results[$key] = $result;
         }
 
         if ($need && ($count >= $need)) {
           // If we have all the rows we need, break out of the paging query.
           break 2;
         }
       }
 
       if (!$this->rawResultLimit) {
         // If we don't have a load count, we loaded all the results. We do
         // not need to load another page.
         break;
       }
 
       if (count($page) < $this->rawResultLimit) {
         // If we have a load count but the unfiltered results contained fewer
         // objects, we know this was the last page of objects; we do not need
         // to load another page because we can deduce it would be empty.
         break;
       }
 
       if (!$this->disableOverheating) {
         if ($overheat_limit && ($total_seen >= $overheat_limit)) {
           $this->isOverheated = true;
 
           if (!$this->returnPartialResultsOnOverheat) {
             throw new Exception(
               pht(
                 'Query (of class "%s") overheated: examined more than %s '.
                 'raw rows without finding %s visible objects.',
                 get_class($this),
                 new PhutilNumber($overheat_limit),
                 new PhutilNumber($need)));
           }
 
           break;
         }
       }
     } while (true);
 
     $results = $this->didLoadResults($results);
 
     return $results;
   }
 
   private function getPolicyFilter() {
     $filter = new PhabricatorPolicyFilter();
     $filter->setViewer($this->viewer);
     $capabilities = $this->getRequiredCapabilities();
     $filter->requireCapabilities($capabilities);
     $filter->raisePolicyExceptions($this->shouldRaisePolicyExceptions());
 
     return $filter;
   }
 
   protected function getRequiredCapabilities() {
     if ($this->capabilities) {
       return $this->capabilities;
     }
 
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
     );
   }
 
   protected function applyPolicyFilter(array $objects, array $capabilities) {
     if ($this->shouldDisablePolicyFiltering()) {
       return $objects;
     }
     $filter = $this->getPolicyFilter();
     $filter->requireCapabilities($capabilities);
     return $filter->apply($objects);
   }
 
   protected function didRejectResult(PhabricatorPolicyInterface $object) {
     // Some objects (like commits) may be rejected because related objects
     // (like repositories) can not be loaded. In some cases, we may need these
     // related objects to determine the object policy, so it's expected that
     // we may occasionally be unable to determine the policy.
 
     try {
       $policy = $object->getPolicy(PhabricatorPolicyCapability::CAN_VIEW);
     } catch (Exception $ex) {
       $policy = null;
     }
 
     // Mark this object as filtered so handles can render "Restricted" instead
     // of "Unknown".
     $phid = $object->getPHID();
     $this->addPolicyFilteredPHIDs(array($phid => $phid));
 
     $this->getPolicyFilter()->rejectObject(
       $object,
       $policy,
       PhabricatorPolicyCapability::CAN_VIEW);
   }
 
   public function addPolicyFilteredPHIDs(array $phids) {
     $this->policyFilteredPHIDs += $phids;
     if ($this->getParentQuery()) {
       $this->getParentQuery()->addPolicyFilteredPHIDs($phids);
     }
     return $this;
   }
 
 
   public function getIsOverheated() {
     if ($this->isOverheated === null) {
       throw new PhutilInvalidStateException('execute');
     }
     return $this->isOverheated;
   }
 
 
   /**
    * Return a map of all object PHIDs which were loaded in the query but
    * filtered out by policy constraints. This allows a caller to distinguish
    * between objects which do not exist (or, at least, were filtered at the
    * content level) and objects which exist but aren't visible.
    *
    * @return map<phid, phid> Map of object PHIDs which were filtered
    *   by policies.
    * @task exec
    */
   public function getPolicyFilteredPHIDs() {
     return $this->policyFilteredPHIDs;
   }
 
 
 /* -(  Query Workspace  )---------------------------------------------------- */
 
 
   /**
    * Put a map of objects into the query workspace. Many queries perform
    * subqueries, which can eventually end up loading the same objects more than
    * once (often to perform policy checks).
    *
    * For example, loading a user may load the user's profile image, which might
    * load the user object again in order to verify that the viewer has
    * permission to see the file.
    *
    * The "query workspace" allows queries to load objects from elsewhere in a
    * query block instead of refetching them.
    *
    * When using the query workspace, it's important to obey two rules:
    *
    * **Never put objects into the workspace which the viewer may not be able
    * to see**. You need to apply all policy filtering //before// putting
    * objects in the workspace. Otherwise, subqueries may read the objects and
    * use them to permit access to content the user shouldn't be able to view.
    *
    * **Fully enrich objects pulled from the workspace.** After pulling objects
    * from the workspace, you still need to load and attach any additional
    * content the query requests. Otherwise, a query might return objects
    * without requested content.
    *
    * Generally, you do not need to update the workspace yourself: it is
    * automatically populated as a side effect of objects surviving policy
    * filtering.
    *
-   * @param map<phid, PhabricatorPolicyInterface> Objects to add to the query
-   *   workspace.
+   * @param map<phid, PhabricatorPolicyInterface> $objects Objects to add to
+   *   the query workspace.
    * @return this
    * @task workspace
    */
   public function putObjectsInWorkspace(array $objects) {
     $parent = $this->getParentQuery();
     if ($parent) {
       $parent->putObjectsInWorkspace($objects);
       return $this;
     }
 
     assert_instances_of($objects, 'PhabricatorPolicyInterface');
 
     $viewer_fragment = $this->getViewer()->getCacheFragment();
 
     // The workspace is scoped per viewer to prevent accidental contamination.
     if (empty($this->workspace[$viewer_fragment])) {
       $this->workspace[$viewer_fragment] = array();
     }
 
     $this->workspace[$viewer_fragment] += $objects;
 
     return $this;
   }
 
 
   /**
    * Retrieve objects from the query workspace. For more discussion about the
    * workspace mechanism, see @{method:putObjectsInWorkspace}. This method
    * searches both the current query's workspace and the workspaces of parent
    * queries.
    *
-   * @param list<phid> List of PHIDs to retrieve.
+   * @param list<phid> $phids List of PHIDs to retrieve.
    * @return this
    * @task workspace
    */
   public function getObjectsFromWorkspace(array $phids) {
     $parent = $this->getParentQuery();
     if ($parent) {
       return $parent->getObjectsFromWorkspace($phids);
     }
 
     $viewer_fragment = $this->getViewer()->getCacheFragment();
 
     $results = array();
     foreach ($phids as $key => $phid) {
       if (isset($this->workspace[$viewer_fragment][$phid])) {
         $results[$phid] = $this->workspace[$viewer_fragment][$phid];
         unset($phids[$key]);
       }
     }
 
     return $results;
   }
 
 
   /**
    * Mark PHIDs as in flight.
    *
    * PHIDs which are "in flight" are actively being queried for. Using this
    * list can prevent infinite query loops by aborting queries which cycle.
    *
-   * @param list<phid> List of PHIDs which are now in flight.
+   * @param list<phid> $phids List of PHIDs which are now in flight.
    * @return this
    */
   public function putPHIDsInFlight(array $phids) {
     foreach ($phids as $phid) {
       $this->inFlightPHIDs[$phid] = $phid;
     }
     return $this;
   }
 
 
   /**
    * Get PHIDs which are currently in flight.
    *
    * PHIDs which are "in flight" are actively being queried for.
    *
    * @return map<phid, phid> PHIDs currently in flight.
    */
   public function getPHIDsInFlight() {
     $results = $this->inFlightPHIDs;
     if ($this->getParentQuery()) {
       $results += $this->getParentQuery()->getPHIDsInFlight();
     }
     return $results;
   }
 
 
 /* -(  Policy Query Implementation  )---------------------------------------- */
 
 
   /**
    * Get the number of results @{method:loadPage} should load. If the value is
    * 0, @{method:loadPage} should load all available results.
    *
    * @return int The number of results to load, or 0 for all results.
    * @task policyimpl
    */
   final protected function getRawResultLimit() {
     return $this->rawResultLimit;
   }
 
 
   /**
    * Hook invoked before query execution. Generally, implementations should
    * reset any internal cursors.
    *
    * @return void
    * @task policyimpl
    */
   protected function willExecute() {
     return;
   }
 
 
   /**
    * Load a raw page of results. Generally, implementations should load objects
    * from the database. They should attempt to return the number of results
    * hinted by @{method:getRawResultLimit}.
    *
    * @return list<PhabricatorPolicyInterface> List of filterable policy objects.
    * @task policyimpl
    */
   abstract protected function loadPage();
 
 
   /**
    * Update internal state so that the next call to @{method:loadPage} will
    * return new results. Generally, you should adjust a cursor position based
    * on the provided result page.
    *
-   * @param list<PhabricatorPolicyInterface> The current page of results.
+   * @param list<PhabricatorPolicyInterface> $page The current page of results.
    * @return void
    * @task policyimpl
    */
   abstract protected function nextPage(array $page);
 
 
   /**
    * Hook for applying a page filter prior to the privacy filter. This allows
    * you to drop some items from the result set without creating problems with
    * pagination or cursor updates. You can also load and attach data which is
    * required to perform policy filtering.
    *
    * Generally, you should load non-policy data and perform non-policy filtering
    * later, in @{method:didFilterPage}. Strictly fewer objects will make it that
    * far (so the program will load less data) and subqueries from that context
    * can use the query workspace to further reduce query load.
    *
    * This method will only be called if data is available. Implementations
    * do not need to handle the case of no results specially.
    *
-   * @param   list<wild>  Results from `loadPage()`.
+   * @param   list<wild>  $page Results from `loadPage()`.
    * @return  list<PhabricatorPolicyInterface> Objects for policy filtering.
    * @task policyimpl
    */
   protected function willFilterPage(array $page) {
     return $page;
   }
 
   /**
    * Hook for performing additional non-policy loading or filtering after an
    * object has satisfied all policy checks. Generally, this means loading and
    * attaching related data.
    *
    * Subqueries executed during this phase can use the query workspace, which
    * may improve performance or make circular policies resolvable. Data which
    * is not necessary for policy filtering should generally be loaded here.
    *
    * This callback can still filter objects (for example, if attachable data
    * is discovered to not exist), but should not do so for policy reasons.
    *
    * This method will only be called if data is available. Implementations do
    * not need to handle the case of no results specially.
    *
-   * @param list<wild> Results from @{method:willFilterPage()}.
+   * @param list<wild> $page Results from @{method:willFilterPage()}.
    * @return list<PhabricatorPolicyInterface> Objects after additional
    *   non-policy processing.
    */
   protected function didFilterPage(array $page) {
     return $page;
   }
 
 
   /**
    * Hook for removing filtered results from alternate result sets. This
    * hook will be called with any objects which were returned by the query but
    * filtered for policy reasons. The query should remove them from any cached
    * or partial result sets.
    *
-   * @param list<wild>  List of objects that should not be returned by alternate
-   *                    result mechanisms.
+   * @param list<wild>  $results List of objects that should not be returned by
+   *                    alternate result mechanisms.
    * @return void
    * @task policyimpl
    */
   protected function didFilterResults(array $results) {
     return;
   }
 
 
   /**
    * Hook for applying final adjustments before results are returned. This is
    * used by @{class:PhabricatorCursorPagedPolicyAwareQuery} to reverse results
    * that are queried during reverse paging.
    *
-   * @param   list<PhabricatorPolicyInterface> Query results.
+   * @param   list<PhabricatorPolicyInterface> $results Query results.
    * @return  list<PhabricatorPolicyInterface> Final results.
    * @task policyimpl
    */
   protected function didLoadResults(array $results) {
     return $results;
   }
 
 
   /**
    * Allows a subclass to disable policy filtering. This method is dangerous.
    * It should be used only if the query loads data which has already been
    * filtered (for example, because it wraps some other query which uses
    * normal policy filtering).
    *
    * @return bool True to disable all policy filtering.
    * @task policyimpl
    */
   protected function shouldDisablePolicyFiltering() {
     return false;
   }
 
 
   /**
    * If this query belongs to an application, return the application class name
    * here. This will prevent the query from returning results if the viewer can
    * not access the application.
    *
    * If this query does not belong to an application, return `null`.
    *
    * @return string|null Application class name.
    */
   abstract public function getQueryApplicationClass();
 
 
   /**
    * Determine if the viewer has permission to use this query's application.
    * For queries which aren't part of an application, this method always returns
    * true.
    *
    * @return bool True if the viewer has application-level permission to
    *   execute the query.
    */
   public function canViewerUseQueryApplication() {
     $class = $this->getQueryApplicationClass();
     if (!$class) {
       return true;
     }
 
     $viewer = $this->getViewer();
     return PhabricatorApplication::isClassInstalledForViewer($class, $viewer);
   }
 
   private function applyWillFilterPageExtensions(array $page) {
     $bridges = array();
     foreach ($page as $key => $object) {
       if ($object instanceof DoorkeeperBridgedObjectInterface) {
         $bridges[$key] = $object;
       }
     }
 
     if ($bridges) {
       $external_phids = array();
       foreach ($bridges as $bridge) {
         $external_phid = $bridge->getBridgedObjectPHID();
         if ($external_phid) {
           $external_phids[$key] = $external_phid;
         }
       }
 
       if ($external_phids) {
         $external_objects = id(new DoorkeeperExternalObjectQuery())
           ->setViewer($this->getViewer())
           ->withPHIDs($external_phids)
           ->execute();
         $external_objects = mpull($external_objects, null, 'getPHID');
       } else {
         $external_objects = array();
       }
 
       foreach ($bridges as $key => $bridge) {
         $external_phid = idx($external_phids, $key);
         if (!$external_phid) {
           $bridge->attachBridgedObject(null);
           continue;
         }
 
         $external_object = idx($external_objects, $external_phid);
         if (!$external_object) {
           $this->didRejectResult($bridge);
           unset($page[$key]);
           continue;
         }
 
         $bridge->attachBridgedObject($external_object);
       }
     }
 
     return $page;
   }
 
 }
diff --git a/src/infrastructure/storage/lisk/LiskDAO.php b/src/infrastructure/storage/lisk/LiskDAO.php
index 2e81b4641a..1915bd71ee 100644
--- a/src/infrastructure/storage/lisk/LiskDAO.php
+++ b/src/infrastructure/storage/lisk/LiskDAO.php
@@ -1,1912 +1,1922 @@
 <?php
 
 /**
  * Simple object-authoritative data access object that makes it easy to build
  * stuff that you need to save to a database. Basically, it means that the
  * amount of boilerplate code (and, particularly, boilerplate SQL) you need
  * to write is greatly reduced.
  *
  * Lisk makes it fairly easy to build something quickly and end up with
  * reasonably high-quality code when you're done (e.g., getters and setters,
  * objects, transactions, reasonably structured OO code). It's also very thin:
  * you can break past it and use MySQL and other lower-level tools when you
  * need to in those couple of cases where it doesn't handle your workflow
  * gracefully.
  *
  * However, Lisk won't scale past one database and lacks many of the features
  * of modern DAOs like Hibernate: for instance, it does not support joins or
  * polymorphic storage.
  *
  * This means that Lisk is well-suited for tools like Differential, but often a
  * poor choice elsewhere. And it is strictly unsuitable for many projects.
  *
  * Lisk's model is object-authoritative: the PHP class definition is the
  * master authority for what the object looks like.
  *
  * =Building New Objects=
  *
  * To create new Lisk objects, extend @{class:LiskDAO} and implement
  * @{method:establishLiveConnection}. It should return an
  * @{class:AphrontDatabaseConnection}; this will tell Lisk where to save your
  * objects.
  *
  *   class Dog extends LiskDAO {
  *
  *     protected $name;
  *     protected $breed;
  *
  *     public function establishLiveConnection() {
  *       return $some_connection_object;
  *     }
  *   }
  *
  * Now, you should create your table:
  *
  *   lang=sql
  *   CREATE TABLE dog (
  *     id int unsigned not null auto_increment primary key,
  *     name varchar(32) not null,
  *     breed varchar(32) not null,
  *     dateCreated int unsigned not null,
  *     dateModified int unsigned not null
  *   );
  *
  * For each property in your class, add a column with the same name to the table
  * (see @{method:getConfiguration} for information about changing this mapping).
  * Additionally, you should create the three columns `id`,  `dateCreated` and
  * `dateModified`. Lisk will automatically manage these, using them to implement
  * autoincrement IDs and timestamps. If you do not want to use these features,
  * see @{method:getConfiguration} for information on disabling them. At a bare
  * minimum, you must normally have an `id` column which is a primary or unique
  * key with a numeric type, although you can change its name by overriding
  * @{method:getIDKey} or disable it entirely by overriding @{method:getIDKey} to
  * return null. Note that many methods rely on a single-part primary key and
  * will no longer work (they will throw) if you disable it.
  *
  * As you add more properties to your class in the future, remember to add them
  * to the database table as well.
  *
  * Lisk will now automatically handle these operations: getting and setting
  * properties, saving objects, loading individual objects, loading groups
  * of objects, updating objects, managing IDs, updating timestamps whenever
  * an object is created or modified, and some additional specialized
  * operations.
  *
  * = Creating, Retrieving, Updating, and Deleting =
  *
  * To create and persist a Lisk object, use @{method:save}:
  *
  *   $dog = id(new Dog())
  *     ->setName('Sawyer')
  *     ->setBreed('Pug')
  *     ->save();
  *
  * Note that **Lisk automatically builds getters and setters for all of your
  * object's protected properties** via @{method:__call}. If you want to add
  * custom behavior to your getters or setters, you can do so by overriding the
  * @{method:readField} and @{method:writeField} methods.
  *
  * Calling @{method:save} will persist the object to the database. After calling
  * @{method:save}, you can call @{method:getID} to retrieve the object's ID.
  *
  * To load objects by ID, use the @{method:load} method:
  *
  *   $dog = id(new Dog())->load($id);
  *
  * This will load the Dog record with ID $id into $dog, or `null` if no such
  * record exists (@{method:load} is an instance method rather than a static
  * method because PHP does not support late static binding, at least until PHP
  * 5.3).
  *
  * To update an object, change its properties and save it:
  *
  *   $dog->setBreed('Lab')->save();
  *
  * To delete an object, call @{method:delete}:
  *
  *   $dog->delete();
  *
  * That's Lisk CRUD in a nutshell.
  *
  * = Queries =
  *
  * Often, you want to load a bunch of objects, or execute a more specialized
  * query. Use @{method:loadAllWhere} or @{method:loadOneWhere} to do this:
  *
  *   $pugs = $dog->loadAllWhere('breed = %s', 'Pug');
  *   $sawyer = $dog->loadOneWhere('name = %s', 'Sawyer');
  *
  * These methods work like @{function@arcanist:queryfx}, but only take half of
  * a query (the part after the WHERE keyword). Lisk will handle the connection,
  * columns, and object construction; you are responsible for the rest of it.
  * @{method:loadAllWhere} returns a list of objects, while
  * @{method:loadOneWhere} returns a single object (or `null`).
  *
  * There's also a @{method:loadRelatives} method which helps to prevent the 1+N
  * queries problem.
  *
  * = Managing Transactions =
  *
  * Lisk uses a transaction stack, so code does not generally need to be aware
  * of the transactional state of objects to implement correct transaction
  * semantics:
  *
  *   $obj->openTransaction();
  *     $obj->save();
  *     $other->save();
  *     // ...
  *     $other->openTransaction();
  *       $other->save();
  *       $another->save();
  *     if ($some_condition) {
  *       $other->saveTransaction();
  *     } else {
  *       $other->killTransaction();
  *     }
  *     // ...
  *   $obj->saveTransaction();
  *
  * Assuming ##$obj##, ##$other## and ##$another## live on the same database,
  * this code will work correctly by establishing savepoints.
  *
  * Selects whose data are used later in the transaction should be included in
  * @{method:beginReadLocking} or @{method:beginWriteLocking} block.
  *
  * @task   conn    Managing Connections
  * @task   config  Configuring Lisk
  * @task   load    Loading Objects
  * @task   info    Examining Objects
  * @task   save    Writing Objects
  * @task   hook    Hooks and Callbacks
  * @task   util    Utilities
  * @task   xaction Managing Transactions
  * @task   isolate Isolation for Unit Testing
  */
 abstract class LiskDAO extends Phobject
   implements AphrontDatabaseTableRefInterface {
 
   const CONFIG_IDS                  = 'id-mechanism';
   const CONFIG_TIMESTAMPS           = 'timestamps';
   const CONFIG_AUX_PHID             = 'auxiliary-phid';
   const CONFIG_SERIALIZATION        = 'col-serialization';
   const CONFIG_BINARY               = 'binary';
   const CONFIG_COLUMN_SCHEMA        = 'col-schema';
   const CONFIG_KEY_SCHEMA           = 'key-schema';
   const CONFIG_NO_TABLE             = 'no-table';
   const CONFIG_NO_MUTATE            = 'no-mutate';
 
   const SERIALIZATION_NONE          = 'id';
   const SERIALIZATION_JSON          = 'json';
   const SERIALIZATION_PHP           = 'php';
 
   const IDS_AUTOINCREMENT           = 'ids-auto';
   const IDS_COUNTER                 = 'ids-counter';
   const IDS_MANUAL                  = 'ids-manual';
 
   const COUNTER_TABLE_NAME          = 'lisk_counter';
 
   private static $processIsolationLevel     = 0;
   private static $transactionIsolationLevel = 0;
 
   private $ephemeral = false;
   private $forcedConnection;
 
   private static $connections       = array();
 
   private static $liskMetadata = array();
 
   protected $id;
   protected $phid;
   protected $dateCreated;
   protected $dateModified;
 
   /**
    *  Build an empty object.
    *
    *  @return obj Empty object.
    */
   public function __construct() {
     $id_key = $this->getIDKey();
     if ($id_key) {
       $this->$id_key = null;
     }
   }
 
 
 /* -(  Managing Connections  )----------------------------------------------- */
 
 
   /**
    * Establish a live connection to a database service. This method should
    * return a new connection. Lisk handles connection caching and management;
    * do not perform caching deeper in the stack.
    *
-   * @param string Mode, either 'r' (reading) or 'w' (reading and writing).
+   * @param string $mode Mode, either 'r' (reading) or 'w' (reading and
+   *   writing).
    * @return AphrontDatabaseConnection New database connection.
    * @task conn
    */
   abstract protected function establishLiveConnection($mode);
 
 
   /**
    * Return a namespace for this object's connections in the connection cache.
    * Generally, the database name is appropriate. Two connections are considered
    * equivalent if they have the same connection namespace and mode.
    *
    * @return string Connection namespace for cache
    * @task conn
    */
   protected function getConnectionNamespace() {
     return $this->getDatabaseName();
   }
 
   abstract protected function getDatabaseName();
 
   /**
    * Get an existing, cached connection for this object.
    *
-   * @param mode Connection mode.
+   * @param mode $mode Connection mode.
    * @return AphrontDatabaseConnection|null  Connection, if it exists in cache.
    * @task conn
    */
   protected function getEstablishedConnection($mode) {
     $key = $this->getConnectionNamespace().':'.$mode;
     if (isset(self::$connections[$key])) {
       return self::$connections[$key];
     }
     return null;
   }
 
 
   /**
    * Store a connection in the connection cache.
    *
-   * @param mode Connection mode.
-   * @param AphrontDatabaseConnection Connection to cache.
+   * @param mode $mode Connection mode.
+   * @param AphrontDatabaseConnection $connection Connection to cache.
+   * @param bool? $force_unique
    * @return this
    * @task conn
    */
   protected function setEstablishedConnection(
     $mode,
     AphrontDatabaseConnection $connection,
     $force_unique = false) {
 
     $key = $this->getConnectionNamespace().':'.$mode;
 
     if ($force_unique) {
       $key .= ':unique';
       while (isset(self::$connections[$key])) {
         $key .= '!';
       }
     }
 
     self::$connections[$key] = $connection;
     return $this;
   }
 
 
   /**
    * Force an object to use a specific connection.
    *
    * This overrides all connection management and forces the object to use
    * a specific connection when interacting with the database.
    *
-   * @param AphrontDatabaseConnection Connection to force this object to use.
+   * @param AphrontDatabaseConnection $connection Connection to force this
+   *   object to use.
    * @task conn
    */
   public function setForcedConnection(AphrontDatabaseConnection $connection) {
     $this->forcedConnection = $connection;
     return $this;
   }
 
 
 /* -(  Configuring Lisk  )--------------------------------------------------- */
 
 
   /**
    * Change Lisk behaviors, like ID configuration and timestamps. If you want
    * to change these behaviors, you should override this method in your child
    * class and change the options you're interested in. For example:
    *
    *   protected function getConfiguration() {
    *     return array(
    *       Lisk_DataAccessObject::CONFIG_EXAMPLE => true,
    *     ) + parent::getConfiguration();
    *   }
    *
    * The available options are:
    *
    * CONFIG_IDS
    * Lisk objects need to have a unique identifying ID. The three mechanisms
    * available for generating this ID are IDS_AUTOINCREMENT (default, assumes
    * the ID column is an autoincrement primary key), IDS_MANUAL (you are taking
    * full responsibility for ID management), or IDS_COUNTER (see below).
    *
    * InnoDB does not persist the value of `auto_increment` across restarts,
    * and instead initializes it to `MAX(id) + 1` during startup. This means it
    * may reissue the same autoincrement ID more than once, if the row is deleted
    * and then the database is restarted. To avoid this, you can set an object to
    * use a counter table with IDS_COUNTER. This will generally behave like
    * IDS_AUTOINCREMENT, except that the counter value will persist across
    * restarts and inserts will be slightly slower. If a database stores any
    * DAOs which use this mechanism, you must create a table there with this
    * schema:
    *
    *   CREATE TABLE lisk_counter (
    *     counterName VARCHAR(64) COLLATE utf8_bin PRIMARY KEY,
    *     counterValue BIGINT UNSIGNED NOT NULL
    *   ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    *
    * CONFIG_TIMESTAMPS
    * Lisk can automatically handle keeping track of a `dateCreated' and
    * `dateModified' column, which it will update when it creates or modifies
    * an object. If you don't want to do this, you may disable this option.
    * By default, this option is ON.
    *
    * CONFIG_AUX_PHID
    * This option can be enabled by being set to some truthy value. The meaning
    * of this value is defined by your PHID generation mechanism. If this option
    * is enabled, a `phid' property will be populated with a unique PHID when an
    * object is created (or if it is saved and does not currently have one). You
    * need to override generatePHID() and hook it into your PHID generation
    * mechanism for this to work. By default, this option is OFF.
    *
    * CONFIG_SERIALIZATION
    * You can optionally provide a column serialization map that will be applied
    * to values when they are written to the database. For example:
    *
    *   self::CONFIG_SERIALIZATION => array(
    *     'complex' => self::SERIALIZATION_JSON,
    *   )
    *
    * This will cause Lisk to JSON-serialize the 'complex' field before it is
    * written, and unserialize it when it is read.
    *
    * CONFIG_BINARY
    * You can optionally provide a map of columns to a flag indicating that
    * they store binary data. These columns will not raise an error when
    * handling binary writes.
    *
    * CONFIG_COLUMN_SCHEMA
    * Provide a map of columns to schema column types.
    *
    * CONFIG_KEY_SCHEMA
    * Provide a map of key names to key specifications.
    *
    * CONFIG_NO_TABLE
    * Allows you to specify that this object does not actually have a table in
    * the database.
    *
    * CONFIG_NO_MUTATE
    * Provide a map of columns which should not be included in UPDATE statements.
    * If you have some columns which are always written to explicitly and should
    * never be overwritten by a save(), you can specify them here. This is an
    * advanced, specialized feature and there are usually better approaches for
    * most locking/contention problems.
    *
    * @return dictionary  Map of configuration options to values.
    *
    * @task   config
    */
   protected function getConfiguration() {
     return array(
       self::CONFIG_IDS                      => self::IDS_AUTOINCREMENT,
       self::CONFIG_TIMESTAMPS               => true,
     );
   }
 
 
   /**
-   *  Determine the setting of a configuration option for this class of objects.
+   * Determine the setting of a configuration option for this class of objects.
    *
-   *  @param  const       Option name, one of the CONFIG_* constants.
-   *  @return mixed       Option value, if configured (null if unavailable).
+   * @param  const  $option_name Option name, one of the CONFIG_* constants.
+   * @return mixed  Option value, if configured (null if unavailable).
    *
-   *  @task   config
+   * @task   config
    */
   public function getConfigOption($option_name) {
     $options = $this->getLiskMetadata('config');
 
     if ($options === null) {
       $options = $this->getConfiguration();
       $this->setLiskMetadata('config', $options);
     }
 
     return idx($options, $option_name);
   }
 
 
 /* -(  Loading Objects  )---------------------------------------------------- */
 
 
   /**
    * Load an object by ID. You need to invoke this as an instance method, not
    * a class method, because PHP doesn't have late static binding (until
    * PHP 5.3.0). For example:
    *
    *   $dog = id(new Dog())->load($dog_id);
    *
-   * @param  int       Numeric ID identifying the object to load.
+   * @param  int       $id Numeric ID identifying the object to load.
    * @return obj|null  Identified object, or null if it does not exist.
    *
    * @task   load
    */
   public function load($id) {
     if (is_object($id)) {
       $id = (string)$id;
     }
 
     if (!$id || (!is_int($id) && !ctype_digit($id))) {
       return null;
     }
 
     return $this->loadOneWhere(
       '%C = %d',
       $this->getIDKey(),
       $id);
   }
 
 
   /**
    * Loads all of the objects, unconditionally.
    *
    * @return dict    Dictionary of all persisted objects of this type, keyed
    *                 on object ID.
    *
    * @task   load
    */
   public function loadAll() {
     return $this->loadAllWhere('1 = 1');
   }
 
 
   /**
    * Load all objects which match a WHERE clause. You provide everything after
    * the 'WHERE'; Lisk handles everything up to it. For example:
    *
    *   $old_dogs = id(new Dog())->loadAllWhere('age > %d', 7);
    *
    * The pattern and arguments are as per queryfx().
    *
-   * @param  string  queryfx()-style SQL WHERE clause.
+   * @param  string  $pattern queryfx()-style SQL WHERE clause.
    * @param  ...     Zero or more conversions.
    * @return dict    Dictionary of matching objects, keyed on ID.
    *
    * @task   load
    */
   public function loadAllWhere($pattern /* , $arg, $arg, $arg ... */) {
     $args = func_get_args();
     $data = call_user_func_array(
       array($this, 'loadRawDataWhere'),
       $args);
     return $this->loadAllFromArray($data);
   }
 
 
   /**
    * Load a single object identified by a 'WHERE' clause. You provide
    * everything after the 'WHERE', and Lisk builds the first half of the
    * query. See loadAllWhere(). This method is similar, but returns a single
    * result instead of a list.
    *
-   * @param  string    queryfx()-style SQL WHERE clause.
+   * @param  string    $pattern queryfx()-style SQL WHERE clause.
    * @param  ...       Zero or more conversions.
    * @return obj|null  Matching object, or null if no object matches.
    *
    * @task   load
    */
   public function loadOneWhere($pattern /* , $arg, $arg, $arg ... */) {
     $args = func_get_args();
     $data = call_user_func_array(
       array($this, 'loadRawDataWhere'),
       $args);
 
     if (count($data) > 1) {
       throw new AphrontCountQueryException(
         pht(
           'More than one result from %s!',
           __FUNCTION__.'()'));
     }
 
     $data = reset($data);
     if (!$data) {
       return null;
     }
 
     return $this->loadFromArray($data);
   }
 
 
   protected function loadRawDataWhere($pattern /* , $args... */) {
     $conn = $this->establishConnection('r');
 
     if ($conn->isReadLocking()) {
       $lock_clause = qsprintf($conn, 'FOR UPDATE');
     } else if ($conn->isWriteLocking()) {
       $lock_clause = qsprintf($conn, 'LOCK IN SHARE MODE');
     } else {
       $lock_clause = qsprintf($conn, '');
     }
 
     $args = func_get_args();
     $args = array_slice($args, 1);
 
     $pattern = 'SELECT * FROM %R WHERE '.$pattern.' %Q';
     array_unshift($args, $this);
     array_push($args, $lock_clause);
     array_unshift($args, $pattern);
 
     return call_user_func_array(array($conn, 'queryData'), $args);
   }
 
 
   /**
    * Reload an object from the database, discarding any changes to persistent
    * properties. This is primarily useful after entering a transaction but
    * before applying changes to an object.
    *
    * @return this
    *
    * @task   load
    */
   public function reload() {
     if (!$this->getID()) {
       throw new Exception(
         pht("Unable to reload object that hasn't been loaded!"));
     }
 
     $result = $this->loadOneWhere(
       '%C = %d',
       $this->getIDKey(),
       $this->getID());
 
     if (!$result) {
       throw new AphrontObjectMissingQueryException();
     }
 
     return $this;
   }
 
 
   /**
    * Initialize this object's properties from a dictionary. Generally, you
    * load single objects with loadOneWhere(), but sometimes it may be more
    * convenient to pull data from elsewhere directly (e.g., a complicated
    * join via @{method:queryData}) and then load from an array representation.
    *
-   * @param  dict  Dictionary of properties, which should be equivalent to
-   *               selecting a row from the table or calling
+   * @param  dict  $row Dictionary of properties, which should be equivalent
+   *               to selecting a row from the table or calling
    *               @{method:getProperties}.
    * @return this
    *
    * @task   load
    */
   public function loadFromArray(array $row) {
     $valid_map = $this->getLiskMetadata('validMap', array());
 
     $map = array();
     $updated = false;
     foreach ($row as $k => $v) {
       // We permit (but ignore) extra properties in the array because a
       // common approach to building the array is to issue a raw SELECT query
       // which may include extra explicit columns or joins.
 
       // This pathway is very hot on some pages, so we're inlining a cache
       // and doing some microoptimization to avoid a strtolower() call for each
       // assignment. The common path (assigning a valid property which we've
       // already seen) always incurs only one empty(). The second most common
       // path (assigning an invalid property which we've already seen) costs
       // an empty() plus an isset().
 
       if (empty($valid_map[$k])) {
         if (isset($valid_map[$k])) {
           // The value is set but empty, which means it's false, so we've
           // already determined it's not valid. We don't need to check again.
           continue;
         }
         $valid_map[$k] = $this->hasProperty($k);
         $updated = true;
         if (!$valid_map[$k]) {
           continue;
         }
       }
 
       $map[$k] = $v;
     }
 
     if ($updated) {
       $this->setLiskMetadata('validMap', $valid_map);
     }
 
     $this->willReadData($map);
 
     foreach ($map as $prop => $value) {
       $this->$prop = $value;
     }
 
     $this->didReadData();
 
     return $this;
   }
 
 
   /**
    * Initialize a list of objects from a list of dictionaries. Usually you
    * load lists of objects with @{method:loadAllWhere}, but sometimes that
    * isn't flexible enough. One case is if you need to do joins to select the
    * right objects:
    *
    *   function loadAllWithOwner($owner) {
    *     $data = $this->queryData(
    *       'SELECT d.*
    *         FROM owner o
    *           JOIN owner_has_dog od ON o.id = od.ownerID
    *           JOIN dog d ON od.dogID = d.id
    *         WHERE o.id = %d',
    *       $owner);
    *     return $this->loadAllFromArray($data);
    *   }
    *
    * This is a lot messier than @{method:loadAllWhere}, but more flexible.
    *
-   * @param  list  List of property dictionaries.
+   * @param  list  $rows List of property dictionaries.
    * @return dict  List of constructed objects, keyed on ID.
    *
    * @task   load
    */
   public function loadAllFromArray(array $rows) {
     $result = array();
 
     $id_key = $this->getIDKey();
 
     foreach ($rows as $row) {
       $obj = clone $this;
       if ($id_key && isset($row[$id_key])) {
         $row_id = $row[$id_key];
 
         if (isset($result[$row_id])) {
           throw new Exception(
             pht(
               'Rows passed to "loadAllFromArray(...)" include two or more '.
               'rows with the same ID ("%s"). Rows must have unique IDs. '.
               'An underlying query may be missing a GROUP BY.',
               $row_id));
         }
 
         $result[$row_id] = $obj->loadFromArray($row);
       } else {
         $result[] = $obj->loadFromArray($row);
       }
     }
 
     return $result;
   }
 
 
 /* -(  Examining Objects  )-------------------------------------------------- */
 
 
   /**
    * Set unique ID identifying this object. You normally don't need to call this
    * method unless with `IDS_MANUAL`.
    *
-   * @param  mixed   Unique ID.
+   * @param  mixed   $id Unique ID.
    * @return this
    * @task   save
    */
   public function setID($id) {
     $id_key = $this->getIDKey();
     $this->$id_key = $id;
     return $this;
   }
 
 
   /**
    * Retrieve the unique ID identifying this object. This value will be null if
    * the object hasn't been persisted and you didn't set it manually.
    *
    * @return mixed   Unique ID.
    *
    * @task   info
    */
   public function getID() {
     $id_key = $this->getIDKey();
     return $this->$id_key;
   }
 
 
   public function getPHID() {
     return $this->phid;
   }
 
 
   /**
    * Test if a property exists.
    *
-   * @param   string    Property name.
+   * @param   string    $property Property name.
    * @return  bool      True if the property exists.
    * @task info
    */
   public function hasProperty($property) {
     return (bool)$this->checkProperty($property);
   }
 
 
   /**
    * Retrieve a list of all object properties. This list only includes
    * properties that are declared as protected, and it is expected that
    * all properties returned by this function should be persisted to the
    * database.
    * Properties that should not be persisted must be declared as private.
    *
    * @return dict  Dictionary of normalized (lowercase) to canonical (original
    *               case) property names.
    *
    * @task   info
    */
   protected function getAllLiskProperties() {
     $properties = $this->getLiskMetadata('properties');
 
     if ($properties === null) {
       $class = new ReflectionClass(static::class);
       $properties = array();
       foreach ($class->getProperties(ReflectionProperty::IS_PROTECTED) as $p) {
         $properties[strtolower($p->getName())] = $p->getName();
       }
 
       $id_key = $this->getIDKey();
       if ($id_key != 'id') {
         unset($properties['id']);
       }
 
       if (!$this->getConfigOption(self::CONFIG_TIMESTAMPS)) {
         unset($properties['datecreated']);
         unset($properties['datemodified']);
       }
 
       if ($id_key != 'phid' && !$this->getConfigOption(self::CONFIG_AUX_PHID)) {
         unset($properties['phid']);
       }
 
       $this->setLiskMetadata('properties', $properties);
     }
 
     return $properties;
   }
 
 
   /**
    * Check if a property exists on this object.
    *
    * @return string|null   Canonical property name, or null if the property
    *                       does not exist.
    *
    * @task   info
    */
   protected function checkProperty($property) {
     $properties = $this->getAllLiskProperties();
 
     $property = strtolower($property);
     if (empty($properties[$property])) {
       return null;
     }
 
     return $properties[$property];
   }
 
 
   /**
    * Get or build the database connection for this object.
    *
-   * @param  string 'r' for read, 'w' for read/write.
-   * @param  bool True to force a new connection. The connection will not
-   *              be retrieved from or saved into the connection cache.
+   * @param  string $mode 'r' for read, 'w' for read/write.
+   * @param  bool? $force_new True to force a new connection. The connection
+   *   will not be retrieved from or saved into the connection cache.
    * @return AphrontDatabaseConnection   Lisk connection object.
    *
    * @task   info
    */
   public function establishConnection($mode, $force_new = false) {
     if ($mode != 'r' && $mode != 'w') {
       throw new Exception(
         pht(
           "Unknown mode '%s', should be 'r' or 'w'.",
           $mode));
     }
 
     if ($this->forcedConnection) {
       return $this->forcedConnection;
     }
 
     if (self::shouldIsolateAllLiskEffectsToCurrentProcess()) {
       $mode = 'isolate-'.$mode;
 
       $connection = $this->getEstablishedConnection($mode);
       if (!$connection) {
         $connection = $this->establishIsolatedConnection($mode);
         $this->setEstablishedConnection($mode, $connection);
       }
 
       return $connection;
     }
 
     if (self::shouldIsolateAllLiskEffectsToTransactions()) {
       // If we're doing fixture transaction isolation, force the mode to 'w'
       // so we always get the same connection for reads and writes, and thus
       // can see the writes inside the transaction.
       $mode = 'w';
     }
 
     // TODO: There is currently no protection on 'r' queries against writing.
 
     $connection = null;
     if (!$force_new) {
       if ($mode == 'r') {
         // If we're requesting a read connection but already have a write
         // connection, reuse the write connection so that reads can take place
         // inside transactions.
         $connection = $this->getEstablishedConnection('w');
       }
 
       if (!$connection) {
         $connection = $this->getEstablishedConnection($mode);
       }
     }
 
     if (!$connection) {
       $connection = $this->establishLiveConnection($mode);
       if (self::shouldIsolateAllLiskEffectsToTransactions()) {
         $connection->openTransaction();
       }
       $this->setEstablishedConnection(
         $mode,
         $connection,
         $force_unique = $force_new);
     }
 
     return $connection;
   }
 
 
   /**
    * Convert this object into a property dictionary. This dictionary can be
    * restored into an object by using @{method:loadFromArray} (unless you're
    * using legacy features with CONFIG_CONVERT_CAMELCASE, but in that case you
    * should just go ahead and die in a fire).
    *
    * @return dict  Dictionary of object properties.
    *
    * @task   info
    */
   protected function getAllLiskPropertyValues() {
     $map = array();
     foreach ($this->getAllLiskProperties() as $p) {
       // We may receive a warning here for properties we've implicitly added
       // through configuration; squelch it.
       $map[$p] = @$this->$p;
     }
     return $map;
   }
 
 
 /* -(  Writing Objects  )---------------------------------------------------- */
 
 
   /**
    * Make an object read-only.
    *
    * Making an object ephemeral indicates that you will be changing state in
    * such a way that you would never ever want it to be written back to the
    * storage.
    */
   public function makeEphemeral() {
     $this->ephemeral = true;
     return $this;
   }
 
   private function isEphemeralCheck() {
     if ($this->ephemeral) {
       throw new LiskEphemeralObjectException();
     }
   }
 
   /**
    * Persist this object to the database. In most cases, this is the only
    * method you need to call to do writes. If the object has not yet been
    * inserted this will do an insert; if it has, it will do an update.
    *
    * @return this
    *
    * @task   save
    */
   public function save() {
     if ($this->shouldInsertWhenSaved()) {
       return $this->insert();
     } else {
       return $this->update();
     }
   }
 
 
   /**
    * Save this object, forcing the query to use REPLACE regardless of object
    * state.
    *
    * @return this
    *
    * @task   save
    */
   public function replace() {
     $this->isEphemeralCheck();
     return $this->insertRecordIntoDatabase('REPLACE');
   }
 
 
   /**
    * Save this object, forcing the query to use INSERT regardless of object
    * state.
    *
    * @return this
    *
    * @task   save
    */
   public function insert() {
     $this->isEphemeralCheck();
     return $this->insertRecordIntoDatabase('INSERT');
   }
 
 
   /**
    * Save this object, forcing the query to use UPDATE regardless of object
    * state.
    *
    * @return this
    *
    * @task   save
    */
   public function update() {
     $this->isEphemeralCheck();
 
     $this->willSaveObject();
     $data = $this->getAllLiskPropertyValues();
 
     // Remove columns flagged as nonmutable from the update statement.
     $no_mutate = $this->getConfigOption(self::CONFIG_NO_MUTATE);
     if ($no_mutate) {
       foreach ($no_mutate as $column) {
         unset($data[$column]);
       }
     }
 
     $this->willWriteData($data);
 
     $map = array();
     foreach ($data as $k => $v) {
       $map[$k] = $v;
     }
 
     $conn = $this->establishConnection('w');
     $binary = $this->getBinaryColumns();
 
     foreach ($map as $key => $value) {
       if (!empty($binary[$key])) {
         $map[$key] = qsprintf($conn, '%C = %nB', $key, $value);
       } else {
         $map[$key] = qsprintf($conn, '%C = %ns', $key, $value);
       }
     }
 
     $id = $this->getID();
     $conn->query(
       'UPDATE %R SET %LQ WHERE %C = '.(is_int($id) ? '%d' : '%s'),
       $this,
       $map,
       $this->getIDKey(),
       $id);
     // We can't detect a missing object because updating an object without
     // changing any values doesn't affect rows. We could jiggle timestamps
     // to catch this for objects which track them if we wanted.
 
     $this->didWriteData();
 
     return $this;
   }
 
 
   /**
    * Delete this object, permanently.
    *
    * @return this
    *
    * @task   save
    */
   public function delete() {
     $this->isEphemeralCheck();
     $this->willDelete();
 
     $conn = $this->establishConnection('w');
     $conn->query(
       'DELETE FROM %R WHERE %C = %d',
       $this,
       $this->getIDKey(),
       $this->getID());
 
     $this->didDelete();
 
     return $this;
   }
 
   /**
    * Internal implementation of INSERT and REPLACE.
    *
-   * @param  const   Either "INSERT" or "REPLACE", to force the desired mode.
+   * @param  const $mode Either "INSERT" or "REPLACE", to force the desired
+   *   mode.
    * @return this
    *
    * @task   save
    */
   protected function insertRecordIntoDatabase($mode) {
     $this->willSaveObject();
     $data = $this->getAllLiskPropertyValues();
 
     $conn = $this->establishConnection('w');
 
     $id_mechanism = $this->getConfigOption(self::CONFIG_IDS);
     switch ($id_mechanism) {
       case self::IDS_AUTOINCREMENT:
         // If we are using autoincrement IDs, let MySQL assign the value for the
         // ID column, if it is empty. If the caller has explicitly provided a
         // value, use it.
         $id_key = $this->getIDKey();
         if (empty($data[$id_key])) {
           unset($data[$id_key]);
         }
         break;
       case self::IDS_COUNTER:
         // If we are using counter IDs, assign a new ID if we don't already have
         // one.
         $id_key = $this->getIDKey();
         if (empty($data[$id_key])) {
           $counter_name = $this->getTableName();
           $id = self::loadNextCounterValue($conn, $counter_name);
           $this->setID($id);
           $data[$id_key] = $id;
         }
         break;
       case self::IDS_MANUAL:
         break;
       default:
         throw new Exception(pht('Unknown %s mechanism!', 'CONFIG_IDs'));
     }
 
     $this->willWriteData($data);
 
     $columns = array_keys($data);
     $binary = $this->getBinaryColumns();
 
     foreach ($data as $key => $value) {
       try {
         if (!empty($binary[$key])) {
           $data[$key] = qsprintf($conn, '%nB', $value);
         } else {
           $data[$key] = qsprintf($conn, '%ns', $value);
         }
       } catch (AphrontParameterQueryException $parameter_exception) {
         throw new PhutilProxyException(
           pht(
             "Unable to insert or update object of class %s, field '%s' ".
             "has a non-scalar value.",
             get_class($this),
             $key),
           $parameter_exception);
       }
     }
 
     switch ($mode) {
       case 'INSERT':
         $verb = qsprintf($conn, 'INSERT');
         break;
       case 'REPLACE':
         $verb = qsprintf($conn, 'REPLACE');
         break;
       default:
         throw new Exception(
           pht(
             'Insert mode verb "%s" is not recognized, use INSERT or REPLACE.',
             $mode));
     }
 
     $conn->query(
       '%Q INTO %R (%LC) VALUES (%LQ)',
       $verb,
       $this,
       $columns,
       $data);
 
     // Only use the insert id if this table is using auto-increment ids
     if ($id_mechanism === self::IDS_AUTOINCREMENT) {
       $this->setID($conn->getInsertID());
     }
 
     $this->didWriteData();
 
     return $this;
   }
 
 
   /**
    * Method used to determine whether to insert or update when saving.
    *
    * @return bool true if the record should be inserted
    */
   protected function shouldInsertWhenSaved() {
     $key_type = $this->getConfigOption(self::CONFIG_IDS);
 
     if ($key_type == self::IDS_MANUAL) {
       throw new Exception(
         pht(
           'You are using manual IDs. You must override the %s method '.
           'to properly detect when to insert a new record.',
           __FUNCTION__.'()'));
     } else {
       return !$this->getID();
     }
   }
 
 
 /* -(  Hooks and Callbacks  )------------------------------------------------ */
 
 
   /**
    * Retrieve the database table name. By default, this is the class name.
    *
    * @return string  Table name for object storage.
    *
    * @task   hook
    */
   public function getTableName() {
     return get_class($this);
   }
 
 
   /**
    * Retrieve the primary key column, "id" by default. If you can not
    * reasonably name your ID column "id", override this method.
    *
    * @return string  Name of the ID column.
    *
    * @task   hook
    */
   public function getIDKey() {
     return 'id';
   }
 
   /**
    * Generate a new PHID, used by CONFIG_AUX_PHID.
    *
    * @return phid    Unique, newly allocated PHID.
    *
    * @task   hook
    */
   public function generatePHID() {
     $type = $this->getPHIDType();
     return PhabricatorPHID::generateNewPHID($type);
   }
 
   public function getPHIDType() {
     throw new PhutilMethodNotImplementedException();
   }
 
 
   /**
    * Hook to apply serialization or validation to data before it is written to
    * the database. See also @{method:willReadData}.
    *
    * @task hook
    */
   protected function willWriteData(array &$data) {
     $this->applyLiskDataSerialization($data, false);
   }
 
 
   /**
    * Hook to perform actions after data has been written to the database.
    *
    * @task hook
    */
   protected function didWriteData() {}
 
 
   /**
    * Hook to make internal object state changes prior to INSERT, REPLACE or
    * UPDATE.
    *
    * @task hook
    */
   protected function willSaveObject() {
     $use_timestamps = $this->getConfigOption(self::CONFIG_TIMESTAMPS);
 
     if ($use_timestamps) {
       if (!$this->getDateCreated()) {
         $this->setDateCreated(time());
       }
       $this->setDateModified(time());
     }
 
     if ($this->getConfigOption(self::CONFIG_AUX_PHID) && !$this->getPHID()) {
       $this->setPHID($this->generatePHID());
     }
   }
 
 
   /**
    * Hook to apply serialization or validation to data as it is read from the
    * database. See also @{method:willWriteData}.
    *
    * @task hook
    */
   protected function willReadData(array &$data) {
     $this->applyLiskDataSerialization($data, $deserialize = true);
   }
 
   /**
    * Hook to perform an action on data after it is read from the database.
    *
    * @task hook
    */
   protected function didReadData() {}
 
   /**
    * Hook to perform an action before the deletion of an object.
    *
    * @task hook
    */
   protected function willDelete() {}
 
   /**
    * Hook to perform an action after the deletion of an object.
    *
    * @task hook
    */
   protected function didDelete() {}
 
   /**
    * Reads the value from a field. Override this method for custom behavior
    * of @{method:getField} instead of overriding getField directly.
    *
-   * @param  string  Canonical field name
+   * @param  string  $field Canonical field name
    * @return mixed   Value of the field
    *
    * @task hook
    */
   protected function readField($field) {
     if (isset($this->$field)) {
       return $this->$field;
     }
     return null;
   }
 
   /**
    * Writes a value to a field. Override this method for custom behavior of
    * setField($value) instead of overriding setField directly.
    *
-   * @param  string  Canonical field name
-   * @param  mixed   Value to write
+   * @param  string  $field Canonical field name
+   * @param  mixed   $value Value to write
    *
    * @task hook
    */
   protected function writeField($field, $value) {
     $this->$field = $value;
   }
 
 
 /* -(  Manging Transactions  )----------------------------------------------- */
 
 
   /**
    * Increase transaction stack depth.
    *
    * @return this
    */
   public function openTransaction() {
     $this->establishConnection('w')->openTransaction();
     return $this;
   }
 
 
   /**
    * Decrease transaction stack depth, saving work.
    *
    * @return this
    */
   public function saveTransaction() {
     $this->establishConnection('w')->saveTransaction();
     return $this;
   }
 
 
   /**
    * Decrease transaction stack depth, discarding work.
    *
    * @return this
    */
   public function killTransaction() {
     $this->establishConnection('w')->killTransaction();
     return $this;
   }
 
 
   /**
    * Begins read-locking selected rows with SELECT ... FOR UPDATE, so that
    * other connections can not read them (this is an enormous oversimplification
    * of FOR UPDATE semantics; consult the MySQL documentation for details). To
    * end read locking, call @{method:endReadLocking}. For example:
    *
    *   $beach->openTransaction();
    *     $beach->beginReadLocking();
    *
    *       $beach->reload();
    *       $beach->setGrainsOfSand($beach->getGrainsOfSand() + 1);
    *       $beach->save();
    *
    *     $beach->endReadLocking();
    *   $beach->saveTransaction();
    *
    * @return this
    * @task xaction
    */
   public function beginReadLocking() {
     $this->establishConnection('w')->beginReadLocking();
     return $this;
   }
 
 
   /**
    * Ends read-locking that began at an earlier @{method:beginReadLocking} call.
    *
    * @return this
    * @task xaction
    */
   public function endReadLocking() {
     $this->establishConnection('w')->endReadLocking();
     return $this;
   }
 
   /**
    * Begins write-locking selected rows with SELECT ... LOCK IN SHARE MODE, so
    * that other connections can not update or delete them (this is an
    * oversimplification of LOCK IN SHARE MODE semantics; consult the
    * MySQL documentation for details). To end write locking, call
    * @{method:endWriteLocking}.
    *
    * @return this
    * @task xaction
    */
   public function beginWriteLocking() {
     $this->establishConnection('w')->beginWriteLocking();
     return $this;
   }
 
 
   /**
    * Ends write-locking that began at an earlier @{method:beginWriteLocking}
    * call.
    *
    * @return this
    * @task xaction
    */
   public function endWriteLocking() {
     $this->establishConnection('w')->endWriteLocking();
     return $this;
   }
 
 
 /* -(  Isolation  )---------------------------------------------------------- */
 
 
   /**
    * @task isolate
    */
   public static function beginIsolateAllLiskEffectsToCurrentProcess() {
     self::$processIsolationLevel++;
   }
 
   /**
    * @task isolate
    */
   public static function endIsolateAllLiskEffectsToCurrentProcess() {
     self::$processIsolationLevel--;
     if (self::$processIsolationLevel < 0) {
       throw new Exception(
         pht('Lisk process isolation level was reduced below 0.'));
     }
   }
 
   /**
    * @task isolate
    */
   public static function shouldIsolateAllLiskEffectsToCurrentProcess() {
     return (bool)self::$processIsolationLevel;
   }
 
   /**
    * @task isolate
    */
   private function establishIsolatedConnection($mode) {
     $config = array();
     return new AphrontIsolatedDatabaseConnection($config);
   }
 
   /**
    * @task isolate
    */
   public static function beginIsolateAllLiskEffectsToTransactions() {
     if (self::$transactionIsolationLevel === 0) {
       self::closeAllConnections();
     }
     self::$transactionIsolationLevel++;
   }
 
   /**
    * @task isolate
    */
   public static function endIsolateAllLiskEffectsToTransactions() {
     self::$transactionIsolationLevel--;
     if (self::$transactionIsolationLevel < 0) {
       throw new Exception(
         pht('Lisk transaction isolation level was reduced below 0.'));
     } else if (self::$transactionIsolationLevel == 0) {
       foreach (self::$connections as $key => $conn) {
         if ($conn) {
           $conn->killTransaction();
         }
       }
       self::closeAllConnections();
     }
   }
 
   /**
    * @task isolate
    */
   public static function shouldIsolateAllLiskEffectsToTransactions() {
     return (bool)self::$transactionIsolationLevel;
   }
 
   /**
    * Close any connections with no recent activity.
    *
    * Long-running processes can use this method to clean up connections which
    * have not been used recently.
    *
-   * @param int Close connections with no activity for this many seconds.
+   * @param int $idle_window Close connections with no activity for this many
+   *   seconds.
    * @return void
    */
   public static function closeInactiveConnections($idle_window) {
     $connections = self::$connections;
 
     $now = PhabricatorTime::getNow();
     foreach ($connections as $key => $connection) {
       // If the connection is not idle, never consider it inactive.
       if (!$connection->isIdle()) {
         continue;
       }
 
       $last_active = $connection->getLastActiveEpoch();
 
       $idle_duration = ($now - $last_active);
       if ($idle_duration <= $idle_window) {
         continue;
       }
 
       self::closeConnection($key);
     }
   }
 
 
   public static function closeAllConnections() {
     $connections = self::$connections;
 
     foreach ($connections as $key => $connection) {
       self::closeConnection($key);
     }
   }
 
   public static function closeIdleConnections() {
     $connections = self::$connections;
 
     foreach ($connections as $key => $connection) {
       if (!$connection->isIdle()) {
         continue;
       }
 
       self::closeConnection($key);
     }
   }
 
   private static function closeConnection($key) {
     if (empty(self::$connections[$key])) {
       throw new Exception(
         pht(
           'No database connection with connection key "%s" exists!',
           $key));
     }
 
     $connection = self::$connections[$key];
     unset(self::$connections[$key]);
 
     $connection->close();
   }
 
 
 /* -(  Utilities  )---------------------------------------------------------- */
 
 
   /**
    * Applies configured serialization to a dictionary of values.
    *
    * @task util
    */
   protected function applyLiskDataSerialization(array &$data, $deserialize) {
     $serialization = $this->getConfigOption(self::CONFIG_SERIALIZATION);
     if ($serialization) {
       foreach (array_intersect_key($serialization, $data) as $col => $format) {
         switch ($format) {
           case self::SERIALIZATION_NONE:
             break;
           case self::SERIALIZATION_PHP:
             if ($deserialize) {
               $data[$col] = unserialize($data[$col]);
             } else {
               $data[$col] = serialize($data[$col]);
             }
             break;
           case self::SERIALIZATION_JSON:
             if ($deserialize) {
               $data[$col] = json_decode($data[$col], true);
             } else {
               $data[$col] = phutil_json_encode($data[$col]);
             }
             break;
           default:
             throw new Exception(
               pht("Unknown serialization format '%s'.", $format));
         }
       }
     }
   }
 
   /**
    * Black magic. Builds implied get*() and set*() for all properties.
    *
-   * @param  string  Method name.
-   * @param  list    Argument vector.
+   * @param  string  $method Method name.
+   * @param  list    $args Argument vector.
    * @return mixed   get*() methods return the property value. set*() methods
    *                 return $this.
    * @task   util
    */
   public function __call($method, $args) {
     $dispatch_map = $this->getLiskMetadata('dispatchMap', array());
 
     // NOTE: This method is very performance-sensitive (many thousands of calls
     // per page on some pages), and thus has some silliness in the name of
     // optimizations.
 
     if ($method[0] === 'g') {
       if (isset($dispatch_map[$method])) {
         $property = $dispatch_map[$method];
       } else {
         if (substr($method, 0, 3) !== 'get') {
           throw new Exception(pht("Unable to resolve method '%s'!", $method));
         }
         $property = substr($method, 3);
         if (!($property = $this->checkProperty($property))) {
           throw new Exception(pht('Bad getter call: %s', $method));
         }
         $dispatch_map[$method] = $property;
         $this->setLiskMetadata('dispatchMap', $dispatch_map);
       }
 
       return $this->readField($property);
     }
 
     if ($method[0] === 's') {
       if (isset($dispatch_map[$method])) {
         $property = $dispatch_map[$method];
       } else {
         if (substr($method, 0, 3) !== 'set') {
           throw new Exception(pht("Unable to resolve method '%s'!", $method));
         }
 
         $property = substr($method, 3);
         $property = $this->checkProperty($property);
         if (!$property) {
           throw new Exception(pht('Bad setter call: %s', $method));
         }
         $dispatch_map[$method] = $property;
         $this->setLiskMetadata('dispatchMap', $dispatch_map);
       }
 
       $this->writeField($property, $args[0]);
 
       return $this;
     }
 
     throw new Exception(pht("Unable to resolve method '%s'.", $method));
   }
 
   /**
    * Warns against writing to undeclared property.
    *
    * @task   util
    */
   public function __set($name, $value) {
     // Hack for policy system hints, see PhabricatorPolicyRule for notes.
     if ($name != '_hashKey') {
       phlog(
         pht(
           'Wrote to undeclared property %s.',
           get_class($this).'::$'.$name));
     }
     $this->$name = $value;
   }
 
 
   /**
    * Increments a named counter and returns the next value.
    *
-   * @param   AphrontDatabaseConnection   Database where the counter resides.
-   * @param   string                      Counter name to create or increment.
+   * @param   AphrontDatabaseConnection   $conn_w Database where the counter
+   *                                      resides.
+   * @param   string                      $counter_name Counter name to create
+   *                                      or increment.
    * @return  int                         Next counter value.
    *
    * @task util
    */
   public static function loadNextCounterValue(
     AphrontDatabaseConnection $conn_w,
     $counter_name) {
 
     // NOTE: If an insert does not touch an autoincrement row or call
     // LAST_INSERT_ID(), MySQL normally does not change the value of
     // LAST_INSERT_ID(). This can cause a counter's value to leak to a
     // new counter if the second counter is created after the first one is
     // updated. To avoid this, we insert LAST_INSERT_ID(1), to ensure the
     // LAST_INSERT_ID() is always updated and always set correctly after the
     // query completes.
 
     queryfx(
       $conn_w,
       'INSERT INTO %T (counterName, counterValue) VALUES
           (%s, LAST_INSERT_ID(1))
         ON DUPLICATE KEY UPDATE
           counterValue = LAST_INSERT_ID(counterValue + 1)',
       self::COUNTER_TABLE_NAME,
       $counter_name);
 
     return $conn_w->getInsertID();
   }
 
 
   /**
    * Returns the current value of a named counter.
    *
-   * @param AphrontDatabaseConnection Database where the counter resides.
-   * @param string Counter name to read.
+   * @param AphrontDatabaseConnection $conn_r Database where the counter
+   *   resides.
+   * @param string $counter_name Counter name to read.
    * @return int|null Current value, or `null` if the counter does not exist.
    *
    * @task util
    */
   public static function loadCurrentCounterValue(
     AphrontDatabaseConnection $conn_r,
     $counter_name) {
 
     $row = queryfx_one(
       $conn_r,
       'SELECT counterValue FROM %T WHERE counterName = %s',
       self::COUNTER_TABLE_NAME,
       $counter_name);
     if (!$row) {
       return null;
     }
 
     return (int)$row['counterValue'];
   }
 
 
   /**
    * Overwrite a named counter, forcing it to a specific value.
    *
    * If the counter does not exist, it is created.
    *
-   * @param AphrontDatabaseConnection Database where the counter resides.
-   * @param string Counter name to create or overwrite.
+   * @param AphrontDatabaseConnection $conn_w Database where the counter
+   *   resides.
+   * @param string $counter_name Counter name to create or overwrite.
+   * @param int $counter_value
    * @return void
    *
    * @task util
    */
   public static function overwriteCounterValue(
     AphrontDatabaseConnection $conn_w,
     $counter_name,
     $counter_value) {
 
     queryfx(
       $conn_w,
       'INSERT INTO %T (counterName, counterValue) VALUES (%s, %d)
         ON DUPLICATE KEY UPDATE counterValue = VALUES(counterValue)',
       self::COUNTER_TABLE_NAME,
       $counter_name,
       $counter_value);
   }
 
   private function getBinaryColumns() {
     return $this->getConfigOption(self::CONFIG_BINARY);
   }
 
   public function getSchemaColumns() {
     $custom_map = $this->getConfigOption(self::CONFIG_COLUMN_SCHEMA);
     if (!$custom_map) {
       $custom_map = array();
     }
 
     $serialization = $this->getConfigOption(self::CONFIG_SERIALIZATION);
     if (!$serialization) {
       $serialization = array();
     }
 
     $serialization_map = array(
       self::SERIALIZATION_JSON => 'text',
       self::SERIALIZATION_PHP => 'bytes',
     );
 
     $binary_map = $this->getBinaryColumns();
 
     $id_mechanism = $this->getConfigOption(self::CONFIG_IDS);
     if ($id_mechanism == self::IDS_AUTOINCREMENT) {
       $id_type = 'auto';
     } else {
       $id_type = 'id';
     }
 
     $builtin = array(
       'id' => $id_type,
       'phid' => 'phid',
       'viewPolicy' => 'policy',
       'editPolicy' => 'policy',
       'epoch' => 'epoch',
       'dateCreated' => 'epoch',
       'dateModified' => 'epoch',
     );
 
     $map = array();
     foreach ($this->getAllLiskProperties() as $property) {
       // First, use types specified explicitly in the table configuration.
       if (array_key_exists($property, $custom_map)) {
         $map[$property] = $custom_map[$property];
         continue;
       }
 
       // If we don't have an explicit type, try a builtin type for the
       // column.
       $type = idx($builtin, $property);
       if ($type) {
         $map[$property] = $type;
         continue;
       }
 
       // If the column has serialization, we can infer the column type.
       if (isset($serialization[$property])) {
         $type = idx($serialization_map, $serialization[$property]);
         if ($type) {
           $map[$property] = $type;
           continue;
         }
       }
 
       if (isset($binary_map[$property])) {
         $map[$property] = 'bytes';
         continue;
       }
 
       if ($property === 'spacePHID') {
         $map[$property] = 'phid?';
         continue;
       }
 
       // If the column is named `somethingPHID`, infer it is a PHID.
       if (preg_match('/[a-z]PHID$/', $property)) {
         $map[$property] = 'phid';
         continue;
       }
 
       // If the column is named `somethingID`, infer it is an ID.
       if (preg_match('/[a-z]ID$/', $property)) {
         $map[$property] = 'id';
         continue;
       }
 
       // We don't know the type of this column.
       $map[$property] = PhabricatorConfigSchemaSpec::DATATYPE_UNKNOWN;
     }
 
     return $map;
   }
 
   public function getSchemaKeys() {
     $custom_map = $this->getConfigOption(self::CONFIG_KEY_SCHEMA);
     if (!$custom_map) {
       $custom_map = array();
     }
 
     $default_map = array();
     foreach ($this->getAllLiskProperties() as $property) {
       switch ($property) {
         case 'id':
           $default_map['PRIMARY'] = array(
             'columns' => array('id'),
             'unique' => true,
           );
           break;
         case 'phid':
           $default_map['key_phid'] = array(
             'columns' => array('phid'),
             'unique' => true,
           );
           break;
         case 'spacePHID':
           $default_map['key_space'] = array(
             'columns' => array('spacePHID'),
           );
           break;
       }
     }
 
     return $custom_map + $default_map;
   }
 
   public function getColumnMaximumByteLength($column) {
     $map = $this->getSchemaColumns();
 
     if (!isset($map[$column])) {
       throw new Exception(
         pht(
           'Object (of class "%s") does not have a column "%s".',
           get_class($this),
           $column));
     }
 
     $data_type = $map[$column];
 
     return id(new PhabricatorStorageSchemaSpec())
       ->getMaximumByteLengthForDataType($data_type);
   }
 
   public function getSchemaPersistence() {
     return null;
   }
 
 
 /* -(  AphrontDatabaseTableRefInterface  )----------------------------------- */
 
 
   public function getAphrontRefDatabaseName() {
     return $this->getDatabaseName();
   }
 
   public function getAphrontRefTableName() {
     return $this->getTableName();
   }
 
 
   private function getLiskMetadata($key, $default = null) {
     if (isset(self::$liskMetadata[static::class][$key])) {
       return self::$liskMetadata[static::class][$key];
     }
 
     if (!isset(self::$liskMetadata[static::class])) {
       self::$liskMetadata[static::class] = array();
     }
 
     return idx(self::$liskMetadata[static::class], $key, $default);
   }
 
   private function setLiskMetadata($key, $value) {
     self::$liskMetadata[static::class][$key] = $value;
   }
 
 }
diff --git a/src/infrastructure/util/PhabricatorGlobalLock.php b/src/infrastructure/util/PhabricatorGlobalLock.php
index 2dd38a50c7..c82c2f0604 100644
--- a/src/infrastructure/util/PhabricatorGlobalLock.php
+++ b/src/infrastructure/util/PhabricatorGlobalLock.php
@@ -1,437 +1,437 @@
 <?php
 
 /**
  * Global, MySQL-backed lock. This is a high-reliability, low-performance
  * global lock.
  *
  * The lock is maintained by using GET_LOCK() in MySQL, and automatically
  * released when the connection terminates. Thus, this lock can safely be used
  * to control access to shared resources without implementing any sort of
  * timeout or override logic: the lock can't normally be stuck in a locked state
  * with no process actually holding the lock.
  *
  * However, acquiring the lock is moderately expensive (several network
  * roundtrips). This makes it unsuitable for tasks where lock performance is
  * important.
  *
  *    $lock = PhabricatorGlobalLock::newLock('example');
  *    $lock->lock();
  *      do_contentious_things();
  *    $lock->unlock();
  *
  * NOTE: This lock is not completely global; it is namespaced to the active
  * storage namespace so that unit tests running in separate table namespaces
  * are isolated from one another.
  *
  * @task construct  Constructing Locks
  * @task impl       Implementation
  */
 final class PhabricatorGlobalLock extends PhutilLock {
 
   private $parameters;
   private $conn;
   private $externalConnection;
   private $log;
   private $disableLogging;
 
   private static $pool = array();
 
 
 /* -(  Constructing Locks  )------------------------------------------------- */
 
 
   public static function newLock($name, $parameters = array()) {
     $namespace = PhabricatorLiskDAO::getStorageNamespace();
     $namespace = PhabricatorHash::digestToLength($namespace, 20);
 
     $parts = array();
     ksort($parameters);
     foreach ($parameters as $key => $parameter) {
       if (!preg_match('/^[a-zA-Z0-9]+\z/', $key)) {
         throw new Exception(
           pht(
             'Lock parameter key "%s" must be alphanumeric.',
             $key));
       }
 
       if (!is_scalar($parameter) && !is_null($parameter)) {
         throw new Exception(
           pht(
             'Lock parameter for key "%s" must be a scalar.',
             $key));
       }
 
       $value = phutil_json_encode($parameter);
       $parts[] = "{$key}={$value}";
     }
     $parts = implode(', ', $parts);
 
     $local = "{$name}({$parts})";
     $local = PhabricatorHash::digestToLength($local, 20);
 
     $full_name = "ph:{$namespace}:{$local}";
     $lock = self::getLock($full_name);
     if (!$lock) {
       $lock = new PhabricatorGlobalLock($full_name);
       self::registerLock($lock);
 
       $lock->parameters = $parameters;
     }
 
     return $lock;
   }
 
   /**
    * Use a specific database connection for locking.
    *
    * By default, `PhabricatorGlobalLock` will lock on the "repository" database
    * (somewhat arbitrarily). In most cases this is fine, but this method can
    * be used to lock on a specific connection.
    *
-   * @param  AphrontDatabaseConnection
+   * @param  AphrontDatabaseConnection $conn
    * @return this
    */
   public function setExternalConnection(AphrontDatabaseConnection $conn) {
     if ($this->conn) {
       throw new Exception(
         pht(
           'Lock is already held, and must be released before the '.
           'connection may be changed.'));
     }
     $this->externalConnection = $conn;
     return $this;
   }
 
   public function setDisableLogging($disable) {
     $this->disableLogging = $disable;
     return $this;
   }
 
 
 /* -(  Connection Pool  )---------------------------------------------------- */
 
   public static function getConnectionPoolSize() {
     return count(self::$pool);
   }
 
   public static function clearConnectionPool() {
     self::$pool = array();
   }
 
   public static function newConnection() {
     // NOTE: Use of the "repository" database is somewhat arbitrary, mostly
     // because the first client of locks was the repository daemons.
 
     // We must always use the same database for all locks, because different
     // databases may be on different hosts if the database is partitioned.
 
     // However, we don't access any tables so we could use any valid database.
     // We could build a database-free connection instead, but that's kind of
     // messy and unusual.
 
     $dao = new PhabricatorRepository();
 
     // NOTE: Using "force_new" to make sure each lock is on its own connection.
 
     // See T13627. This is critically important in versions of MySQL older
     // than MySQL 5.7, because they can not hold more than one lock per
     // connection simultaneously.
 
     return $dao->establishConnection('w', $force_new = true);
   }
 
 /* -(  Implementation  )----------------------------------------------------- */
 
   protected function doLock($wait) {
     $conn = $this->conn;
 
     if (!$conn) {
       if ($this->externalConnection) {
         $conn = $this->externalConnection;
       }
     }
 
     if (!$conn) {
       // Try to reuse a connection from the connection pool.
       $conn = array_pop(self::$pool);
     }
 
     if (!$conn) {
       $conn = self::newConnection();
     }
 
     // See T13627. We must never hold more than one lock per connection, so
     // make sure this connection has no existing locks. (Normally, we should
     // only be able to get here if callers explicitly provide the same external
     // connection to multiple locks.)
 
     if ($conn->isHoldingAnyLock()) {
       throw new Exception(
         pht(
           'Unable to establish lock on connection: this connection is '.
           'already holding a lock. Acquiring a second lock on the same '.
           'connection would release the first lock in MySQL versions '.
           'older than 5.7.'));
     }
 
     // NOTE: Since MySQL will disconnect us if we're idle for too long, we set
     // the wait_timeout to an enormous value, to allow us to hold the
     // connection open indefinitely (or, at least, for 24 days).
     $max_allowed_timeout = 2147483;
     queryfx($conn, 'SET wait_timeout = %d', $max_allowed_timeout);
 
     $lock_name = $this->getName();
 
     $result = queryfx_one(
       $conn,
       'SELECT GET_LOCK(%s, %f)',
       $lock_name,
       $wait);
 
     $ok = head($result);
     if (!$ok) {
 
       // See PHI1794. We failed to acquire the lock, but the connection itself
       // is still good. We're done with it, so add it to the pool, just as we
       // would if we were releasing the lock.
 
       // If we don't do this, we may establish a huge number of connections
       // very rapidly if many workers try to acquire a lock at once. For
       // example, this can happen if there are a large number of webhook tasks
       // in the queue.
 
       // See T13627. If this is an external connection, don't put it into
       // the shared connection pool.
 
       if (!$this->externalConnection) {
         self::$pool[] = $conn;
       }
 
       throw id(new PhutilLockException($lock_name))
         ->setHint($this->newHint($lock_name, $wait));
     }
 
     $conn->rememberLock($lock_name);
 
     $this->conn = $conn;
 
     if ($this->shouldLogLock()) {
       $lock_context = $this->newLockContext();
 
       $log = id(new PhabricatorDaemonLockLog())
         ->setLockName($lock_name)
         ->setLockParameters($this->parameters)
         ->setLockContext($lock_context)
         ->save();
 
       $this->log = $log;
     }
   }
 
   protected function doUnlock() {
     $lock_name = $this->getName();
 
     $conn = $this->conn;
 
     try {
       $result = queryfx_one(
         $conn,
         'SELECT RELEASE_LOCK(%s)',
         $lock_name);
       $conn->forgetLock($lock_name);
     } catch (Exception $ex) {
       $result = array(null);
     }
 
     $ok = head($result);
     if (!$ok) {
       // TODO: We could throw here, but then this lock doesn't get marked
       // unlocked and we throw again later when exiting. It also doesn't
       // particularly matter for any current applications. For now, just
       // swallow the error.
     }
 
     $this->conn = null;
 
     if (!$this->externalConnection) {
       $conn->close();
       self::$pool[] = $conn;
     }
 
     if ($this->log) {
       $log = $this->log;
       $this->log = null;
 
       $conn = $log->establishConnection('w');
       queryfx(
         $conn,
         'UPDATE %T SET lockReleased = UNIX_TIMESTAMP() WHERE id = %d',
         $log->getTableName(),
         $log->getID());
     }
   }
 
   private function shouldLogLock() {
     if ($this->disableLogging) {
       return false;
     }
 
     $policy = id(new PhabricatorDaemonLockLogGarbageCollector())
       ->getRetentionPolicy();
     if (!$policy) {
       return false;
     }
 
     return true;
   }
 
   private function newLockContext() {
     $context = array(
       'pid' => getmypid(),
       'host' => php_uname('n'),
       'sapi' => php_sapi_name(),
     );
 
     global $argv;
     if ($argv) {
       $context['argv'] = $argv;
     }
 
     $access_log = null;
 
     // TODO: There's currently no cohesive way to get the parameterized access
     // log for the current request across different request types. Web requests
     // have an "AccessLog", SSH requests have an "SSHLog", and other processes
     // (like scripts) have no log. But there's no method to say "give me any
     // log you've got". For now, just test if we have a web request and use the
     // "AccessLog" if we do, since that's the only one we actually read any
     // parameters from.
 
     // NOTE: "PhabricatorStartup" is only available from web requests, not
     // from CLI scripts.
     if (class_exists('PhabricatorStartup', false)) {
       $access_log = PhabricatorAccessLog::getLog();
     }
 
     if ($access_log) {
       $controller = $access_log->getData('C');
       if ($controller) {
         $context['controller'] = $controller;
       }
 
       $method = $access_log->getData('m');
       if ($method) {
         $context['method'] = $method;
       }
     }
 
     return $context;
   }
 
   private function newHint($lock_name, $wait) {
     if (!$this->shouldLogLock()) {
       return pht(
         'Enable the lock log for more detailed information about '.
         'which process is holding this lock.');
     }
 
     $now = PhabricatorTime::getNow();
 
     // First, look for recent logs. If other processes have been acquiring and
     // releasing this lock while we've been waiting, this is more likely to be
     // a contention/throughput issue than an issue with something hung while
     // holding the lock.
     $limit = 100;
     $logs = id(new PhabricatorDaemonLockLog())->loadAllWhere(
       'lockName = %s AND dateCreated >= %d ORDER BY id ASC LIMIT %d',
       $lock_name,
       ($now - $wait),
       $limit);
 
     if ($logs) {
       if (count($logs) === $limit) {
         return pht(
           'During the last %s second(s) spent waiting for the lock, more '.
           'than %s other process(es) acquired it, so this is likely a '.
           'bottleneck. Use "bin/lock log --name %s" to review log activity.',
           new PhutilNumber($wait),
           new PhutilNumber($limit),
           $lock_name);
       } else {
         return pht(
           'During the last %s second(s) spent waiting for the lock, %s '.
           'other process(es) acquired it, so this is likely a '.
           'bottleneck. Use "bin/lock log --name %s" to review log activity.',
           new PhutilNumber($wait),
           phutil_count($logs),
           $lock_name);
       }
     }
 
     $last_log = id(new PhabricatorDaemonLockLog())->loadOneWhere(
       'lockName = %s ORDER BY id DESC LIMIT 1',
       $lock_name);
 
     if ($last_log) {
       $info = array();
 
       $acquired = $last_log->getDateCreated();
       $context = $last_log->getLockContext();
 
       $process_info = array();
 
       $pid = idx($context, 'pid');
       if ($pid) {
         $process_info[] = 'pid='.$pid;
       }
 
       $host = idx($context, 'host');
       if ($host) {
         $process_info[] = 'host='.$host;
       }
 
       $sapi = idx($context, 'sapi');
       if ($sapi) {
         $process_info[] = 'sapi='.$sapi;
       }
 
       $argv = idx($context, 'argv');
       if ($argv) {
         $process_info[] = 'argv='.(string)csprintf('%LR', $argv);
       }
 
       $controller = idx($context, 'controller');
       if ($controller) {
         $process_info[] = 'controller='.$controller;
       }
 
       $method = idx($context, 'method');
       if ($method) {
         $process_info[] = 'method='.$method;
       }
 
       $process_info = implode(', ', $process_info);
 
       $info[] = pht(
         'This lock was most recently acquired by a process (%s) '.
         '%s second(s) ago.',
         $process_info,
         new PhutilNumber($now - $acquired));
 
       $released = $last_log->getLockReleased();
       if ($released) {
         $info[] = pht(
           'This lock was released %s second(s) ago.',
           new PhutilNumber($now - $released));
       } else {
         $info[] = pht('There is no record of this lock being released.');
       }
 
       return implode(' ', $info);
     }
 
     return pht(
       'Found no records of processes acquiring or releasing this lock.');
   }
 
 }
diff --git a/src/infrastructure/util/PhabricatorHash.php b/src/infrastructure/util/PhabricatorHash.php
index 8916b37a0e..2b3fe8ab4c 100644
--- a/src/infrastructure/util/PhabricatorHash.php
+++ b/src/infrastructure/util/PhabricatorHash.php
@@ -1,281 +1,282 @@
 <?php
 
 final class PhabricatorHash extends Phobject {
 
   const INDEX_DIGEST_LENGTH = 12;
   const ANCHOR_DIGEST_LENGTH = 12;
 
   /**
    * Digest a string using HMAC+SHA1.
    *
    * Because a SHA1 collision is now known, this method should be considered
    * weak. Callers should prefer @{method:digestWithNamedKey}.
    *
-   * @param   string  Input string.
+   * @param   string  $string Input string.
+   * @param   string? $key
    * @return  string  32-byte hexadecimal SHA1+HMAC hash.
    */
   public static function weakDigest($string, $key = null) {
     if ($key === null) {
       $key = PhabricatorEnv::getEnvConfig('security.hmac-key');
     }
 
     if (!$key) {
       throw new Exception(
         pht(
           "Set a '%s' in your configuration!",
           'security.hmac-key'));
     }
 
     return hash_hmac('sha1', $string, $key);
   }
 
 
   /**
    * Digest a string for use in, e.g., a MySQL index. This produces a short
    * (12-byte), case-sensitive alphanumeric string with 72 bits of entropy,
    * which is generally safe in most contexts (notably, URLs).
    *
    * This method emphasizes compactness, and should not be used for security
    * related hashing (for general purpose hashing, see @{method:digest}).
    *
-   * @param   string  Input string.
+   * @param   string  $string Input string.
    * @return  string  12-byte, case-sensitive, mostly-alphanumeric hash of
    *                  the string.
    */
   public static function digestForIndex($string) {
     $hash = sha1($string, $raw_output = true);
 
     static $map;
     if ($map === null) {
       $map = '0123456789'.
              'abcdefghij'.
              'klmnopqrst'.
              'uvwxyzABCD'.
              'EFGHIJKLMN'.
              'OPQRSTUVWX'.
              'YZ._';
     }
 
     $result = '';
     for ($ii = 0; $ii < self::INDEX_DIGEST_LENGTH; $ii++) {
       $result .= $map[(ord($hash[$ii]) & 0x3F)];
     }
 
     return $result;
   }
 
   /**
    * Digest a string for use in HTML page anchors. This is similar to
    * @{method:digestForIndex} but produces purely alphanumeric output.
    *
    * This tries to be mostly compatible with the index digest to limit how
    * much stuff we're breaking by switching to it. For additional discussion,
    * see T13045.
    *
-   * @param   string  Input string.
+   * @param   string  $string Input string.
    * @return  string  12-byte, case-sensitive, purely-alphanumeric hash of
    *                  the string.
    */
   public static function digestForAnchor($string) {
     $hash = sha1($string, $raw_output = true);
 
     static $map;
     if ($map === null) {
       $map = '0123456789'.
              'abcdefghij'.
              'klmnopqrst'.
              'uvwxyzABCD'.
              'EFGHIJKLMN'.
              'OPQRSTUVWX'.
              'YZ';
     }
 
     $result = '';
     $accum = 0;
     $map_size = strlen($map);
     for ($ii = 0; $ii < self::ANCHOR_DIGEST_LENGTH; $ii++) {
       $byte = ord($hash[$ii]);
       $low_bits = ($byte & 0x3F);
       $accum = ($accum + $byte) % $map_size;
 
       if ($low_bits < $map_size) {
         // If an index digest would produce any alphanumeric character, just
         // use that character. This means that these digests are the same as
         // digests created with "digestForIndex()" in all positions where the
         // output character is some character other than "." or "_".
         $result .= $map[$low_bits];
       } else {
         // If an index digest would produce a non-alphumeric character ("." or
         // "_"), pick an alphanumeric character instead. We accumulate an
         // index into the alphanumeric character list to try to preserve
         // entropy here. We could use this strategy for all bytes instead,
         // but then these digests would differ from digests created with
         // "digestForIndex()" in all positions, instead of just a small number
         // of positions.
         $result .= $map[$accum];
       }
     }
 
     return $result;
   }
 
 
   public static function digestToRange($string, $min, $max) {
     if ($min > $max) {
       throw new Exception(pht('Maximum must be larger than minimum.'));
     }
 
     if ($min == $max) {
       return $min;
     }
 
     $hash = sha1($string, $raw_output = true);
     // Make sure this ends up positive, even on 32-bit machines.
     $value = head(unpack('L', $hash)) & 0x7FFFFFFF;
 
     return $min + ($value % (1 + $max - $min));
   }
 
 
   /**
    * Shorten a string to a maximum byte length in a collision-resistant way
    * while retaining some degree of human-readability.
    *
    * This function converts an input string into a prefix plus a hash. For
    * example, a very long string beginning with "crabapplepie..." might be
    * digested to something like "crabapp-N1wM1Nz3U84k".
    *
    * This allows the maximum length of identifiers to be fixed while
    * maintaining a high degree of collision resistance and a moderate degree
    * of human readability.
    *
-   * @param string The string to shorten.
-   * @param int Maximum length of the result.
+   * @param string $string The string to shorten.
+   * @param int $length Maximum length of the result.
    * @return string String shortened in a collision-resistant way.
    */
   public static function digestToLength($string, $length) {
     // We need at least two more characters than the hash length to fit in a
     // a 1-character prefix and a separator.
     $min_length = self::INDEX_DIGEST_LENGTH + 2;
     if ($length < $min_length) {
       throw new Exception(
         pht(
           'Length parameter in %s must be at least %s, '.
           'but %s was provided.',
           'digestToLength()',
           new PhutilNumber($min_length),
           new PhutilNumber($length)));
     }
 
     // We could conceivably return the string unmodified if it's shorter than
     // the specified length. Instead, always hash it. This makes the output of
     // the method more recognizable and consistent (no surprising new behavior
     // once you hit a string longer than `$length`) and prevents an attacker
     // who can control the inputs from intentionally using the hashed form
     // of a string to cause a collision.
 
     $hash = self::digestForIndex($string);
 
     $prefix = substr($string, 0, ($length - ($min_length - 1)));
 
     return $prefix.'-'.$hash;
   }
 
   public static function digestWithNamedKey($message, $key_name) {
     $key_bytes = self::getNamedHMACKey($key_name);
     return self::digestHMACSHA256($message, $key_bytes);
   }
 
   public static function digestHMACSHA256($message, $key) {
     if (!is_string($message)) {
       throw new Exception(
         pht('HMAC-SHA256 can only digest strings.'));
     }
 
     if (!is_string($key)) {
       throw new Exception(
         pht('HMAC-SHA256 keys must be strings.'));
     }
 
     if (!strlen($key)) {
       throw new Exception(
         pht('HMAC-SHA256 requires a nonempty key.'));
     }
 
     $result = hash_hmac('sha256', $message, $key, $raw_output = false);
 
     // Although "hash_hmac()" is documented as returning `false` when it fails,
     // it can also return `null` if you pass an object as the "$message".
     if ($result === false || $result === null) {
       throw new Exception(
         pht('Unable to compute HMAC-SHA256 digest of message.'));
     }
 
     return $result;
   }
 
 
 /* -(  HMAC Key Management  )------------------------------------------------ */
 
 
   private static function getNamedHMACKey($hmac_name) {
     $cache = PhabricatorCaches::getImmutableCache();
 
     $cache_key = "hmac.key({$hmac_name})";
 
     $hmac_key = $cache->getKey($cache_key);
     if (($hmac_key === null) || !strlen($hmac_key)) {
       $hmac_key = self::readHMACKey($hmac_name);
 
       if ($hmac_key === null) {
         $hmac_key = self::newHMACKey($hmac_name);
         self::writeHMACKey($hmac_name, $hmac_key);
       }
 
       $cache->setKey($cache_key, $hmac_key);
     }
 
     // The "hex2bin()" function doesn't exist until PHP 5.4.0 so just
     // implement it inline.
     $result = '';
     for ($ii = 0; $ii < strlen($hmac_key); $ii += 2) {
       $result .= pack('H*', substr($hmac_key, $ii, 2));
     }
 
     return $result;
   }
 
   private static function newHMACKey($hmac_name) {
     $hmac_key = Filesystem::readRandomBytes(64);
     return bin2hex($hmac_key);
   }
 
   private static function writeHMACKey($hmac_name, $hmac_key) {
     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
 
       id(new PhabricatorAuthHMACKey())
         ->setKeyName($hmac_name)
         ->setKeyValue($hmac_key)
         ->save();
 
     unset($unguarded);
   }
 
   private static function readHMACKey($hmac_name) {
     $table = new PhabricatorAuthHMACKey();
     $conn = $table->establishConnection('r');
 
     $row = queryfx_one(
       $conn,
       'SELECT keyValue FROM %T WHERE keyName = %s',
       $table->getTableName(),
       $hmac_name);
     if (!$row) {
       return null;
     }
 
     return $row['keyValue'];
   }
 
 
 }
diff --git a/src/infrastructure/util/password/PhabricatorPasswordHasher.php b/src/infrastructure/util/password/PhabricatorPasswordHasher.php
index fe35d2c296..ac7c9963e9 100644
--- a/src/infrastructure/util/password/PhabricatorPasswordHasher.php
+++ b/src/infrastructure/util/password/PhabricatorPasswordHasher.php
@@ -1,420 +1,420 @@
 <?php
 
 /**
  * Provides a mechanism for hashing passwords, like "iterated md5", "bcrypt",
  * "scrypt", etc.
  *
  * Hashers define suitability and strength, and the system automatically
  * chooses the strongest available hasher and can prompt users to upgrade as
  * soon as a stronger hasher is available.
  *
  * @task hasher   Implementing a Hasher
  * @task hashing  Using Hashers
  */
 abstract class PhabricatorPasswordHasher extends Phobject {
 
   const MAXIMUM_STORAGE_SIZE = 128;
 
 
 /* -(  Implementing a Hasher  )---------------------------------------------- */
 
 
   /**
    * Return a human-readable description of this hasher, like "Iterated MD5".
    *
    * @return string Human readable hash name.
    * @task hasher
    */
   abstract public function getHumanReadableName();
 
 
   /**
    * Return a short, unique, key identifying this hasher, like "md5" or
    * "bcrypt". This identifier should not be translated.
    *
    * @return string Short, unique hash name.
    * @task hasher
    */
   abstract public function getHashName();
 
 
   /**
    * Return the maximum byte length of hashes produced by this hasher. This is
    * used to prevent storage overflows.
    *
    * @return int  Maximum number of bytes in hashes this class produces.
    * @task hasher
    */
   abstract public function getHashLength();
 
 
   /**
    * Return `true` to indicate that any required extensions or dependencies
    * are available, and this hasher is able to perform hashing.
    *
    * @return bool True if this hasher can execute.
    * @task hasher
    */
   abstract public function canHashPasswords();
 
 
   /**
    * Return a human-readable string describing why this hasher is unable
    * to operate. For example, "To use bcrypt, upgrade to PHP 5.5.0 or newer.".
    *
    * @return string Human-readable description of how to enable this hasher.
    * @task hasher
    */
   abstract public function getInstallInstructions();
 
 
   /**
    * Return an indicator of this hasher's strength. When choosing to hash
    * new passwords, the strongest available hasher which is usable for new
    * passwords will be used, and the presence of a stronger hasher will
    * prompt users to update their hashes.
    *
    * Generally, this method should return a larger number than hashers it is
    * preferable to, but a smaller number than hashers which are better than it
    * is. This number does not need to correspond directly with the actual hash
    * strength.
    *
    * @return float  Strength of this hasher.
    * @task hasher
    */
   abstract public function getStrength();
 
 
   /**
    * Return a short human-readable indicator of this hasher's strength, like
    * "Weak", "Okay", or "Good".
    *
    * This is only used to help administrators make decisions about
    * configuration.
    *
    * @return string Short human-readable description of hash strength.
    * @task hasher
    */
   abstract public function getHumanReadableStrength();
 
 
   /**
    * Produce a password hash.
    *
-   * @param   PhutilOpaqueEnvelope  Text to be hashed.
+   * @param   PhutilOpaqueEnvelope  $envelope Text to be hashed.
    * @return  PhutilOpaqueEnvelope  Hashed text.
    * @task hasher
    */
   abstract protected function getPasswordHash(PhutilOpaqueEnvelope $envelope);
 
 
   /**
    * Verify that a password matches a hash.
    *
    * The default implementation checks for equality; if a hasher embeds salt in
    * hashes it should override this method and perform a salt-aware comparison.
    *
-   * @param   PhutilOpaqueEnvelope  Password to compare.
-   * @param   PhutilOpaqueEnvelope  Bare password hash.
+   * @param   PhutilOpaqueEnvelope  $password Password to compare.
+   * @param   PhutilOpaqueEnvelope  $hash Bare password hash.
    * @return  bool                  True if the passwords match.
    * @task hasher
    */
   protected function verifyPassword(
     PhutilOpaqueEnvelope $password,
     PhutilOpaqueEnvelope $hash) {
 
     $actual_hash = $this->getPasswordHash($password)->openEnvelope();
     $expect_hash = $hash->openEnvelope();
 
     return phutil_hashes_are_identical($actual_hash, $expect_hash);
   }
 
 
   /**
    * Check if an existing hash created by this algorithm is upgradeable.
    *
    * The default implementation returns `false`. However, hash algorithms which
    * have (for example) an internal cost function may be able to upgrade an
    * existing hash to a stronger one with a higher cost.
    *
-   * @param PhutilOpaqueEnvelope  Bare hash.
+   * @param PhutilOpaqueEnvelope  $hash Bare hash.
    * @return bool                 True if the hash can be upgraded without
    *                              changing the algorithm (for example, to a
    *                              higher cost).
    * @task hasher
    */
   protected function canUpgradeInternalHash(PhutilOpaqueEnvelope $hash) {
     return false;
   }
 
 
 /* -(  Using Hashers  )------------------------------------------------------ */
 
 
   /**
    * Get the hash of a password for storage.
    *
-   * @param   PhutilOpaqueEnvelope  Password text.
+   * @param   PhutilOpaqueEnvelope  $envelope Password text.
    * @return  PhutilOpaqueEnvelope  Hashed text.
    * @task hashing
    */
   final public function getPasswordHashForStorage(
     PhutilOpaqueEnvelope $envelope) {
 
     $name = $this->getHashName();
     $hash = $this->getPasswordHash($envelope);
 
     $actual_len = strlen($hash->openEnvelope());
     $expect_len = $this->getHashLength();
     if ($actual_len > $expect_len) {
       throw new Exception(
         pht(
           "Password hash '%s' produced a hash of length %d, but a ".
           "maximum length of %d was expected.",
           $name,
           new PhutilNumber($actual_len),
           new PhutilNumber($expect_len)));
     }
 
     return new PhutilOpaqueEnvelope($name.':'.$hash->openEnvelope());
   }
 
 
   /**
    * Parse a storage hash into its components, like the hash type and hash
    * data.
    *
    * @return map  Dictionary of information about the hash.
    * @task hashing
    */
   private static function parseHashFromStorage(PhutilOpaqueEnvelope $hash) {
     $raw_hash = $hash->openEnvelope();
     if (strpos($raw_hash, ':') === false) {
       throw new Exception(
         pht(
           'Malformed password hash, expected "name:hash".'));
     }
 
     list($name, $hash) = explode(':', $raw_hash);
 
     return array(
       'name' => $name,
       'hash' => new PhutilOpaqueEnvelope($hash),
     );
   }
 
 
   /**
    * Get all available password hashers. This may include hashers which can not
    * actually be used (for example, a required extension is missing).
    *
    * @return list<PhabricatorPasswordHasher> Hasher objects.
    * @task hashing
    */
   public static function getAllHashers() {
     $objects = id(new PhutilClassMapQuery())
       ->setAncestorClass(__CLASS__)
       ->setUniqueMethod('getHashName')
       ->execute();
 
     foreach ($objects as $object) {
       $name = $object->getHashName();
 
       $potential_length = strlen($name) + $object->getHashLength() + 1;
       $maximum_length = self::MAXIMUM_STORAGE_SIZE;
 
       if ($potential_length > $maximum_length) {
         throw new Exception(
           pht(
             'Hasher "%s" may produce hashes which are too long to fit in '.
             'storage. %d characters are available, but its hashes may be '.
             'up to %d characters in length.',
             $name,
             $maximum_length,
             $potential_length));
       }
     }
 
     return $objects;
   }
 
 
   /**
    * Get all usable password hashers. This may include hashers which are
    * not desirable or advisable.
    *
    * @return list<PhabricatorPasswordHasher> Hasher objects.
    * @task hashing
    */
   public static function getAllUsableHashers() {
     $hashers = self::getAllHashers();
     foreach ($hashers as $key => $hasher) {
       if (!$hasher->canHashPasswords()) {
         unset($hashers[$key]);
       }
     }
     return $hashers;
   }
 
 
   /**
    * Get the best (strongest) available hasher.
    *
    * @return PhabricatorPasswordHasher Best hasher.
    * @task hashing
    */
   public static function getBestHasher() {
     $hashers = self::getAllUsableHashers();
     $hashers = msort($hashers, 'getStrength');
 
     $hasher = last($hashers);
     if (!$hasher) {
       throw new PhabricatorPasswordHasherUnavailableException(
         pht(
           'There are no password hashers available which are usable for '.
           'new passwords.'));
     }
 
     return $hasher;
   }
 
 
   /**
    * Get the hasher for a given stored hash.
    *
    * @return PhabricatorPasswordHasher Corresponding hasher.
    * @task hashing
    */
   public static function getHasherForHash(PhutilOpaqueEnvelope $hash) {
     $info = self::parseHashFromStorage($hash);
     $name = $info['name'];
 
     $usable = self::getAllUsableHashers();
     if (isset($usable[$name])) {
       return $usable[$name];
     }
 
     $all = self::getAllHashers();
     if (isset($all[$name])) {
       throw new PhabricatorPasswordHasherUnavailableException(
         pht(
           'Attempting to compare a password saved with the "%s" hash. The '.
           'hasher exists, but is not currently usable. %s',
           $name,
           $all[$name]->getInstallInstructions()));
     }
 
     throw new PhabricatorPasswordHasherUnavailableException(
       pht(
         'Attempting to compare a password saved with the "%s" hash. No such '.
         'hasher is known.',
         $name));
   }
 
 
   /**
    * Test if a password is using an weaker hash than the strongest available
    * hash. This can be used to prompt users to upgrade, or automatically upgrade
    * on login.
    *
    * @return bool True to indicate that rehashing this password will improve
    *              the hash strength.
    * @task hashing
    */
   public static function canUpgradeHash(PhutilOpaqueEnvelope $hash) {
     if (!strlen($hash->openEnvelope())) {
       throw new Exception(
         pht('Expected a password hash, received nothing!'));
     }
 
     $current_hasher = self::getHasherForHash($hash);
     $best_hasher = self::getBestHasher();
 
     if ($current_hasher->getHashName() != $best_hasher->getHashName()) {
       // If the algorithm isn't the best one, we can upgrade.
       return true;
     }
 
     $info = self::parseHashFromStorage($hash);
     if ($current_hasher->canUpgradeInternalHash($info['hash'])) {
       // If the algorithm provides an internal upgrade, we can also upgrade.
       return true;
     }
 
     // Already on the best algorithm with the best settings.
     return false;
   }
 
 
   /**
    * Generate a new hash for a password, using the best available hasher.
    *
-   * @param   PhutilOpaqueEnvelope  Password to hash.
+   * @param   PhutilOpaqueEnvelope  $password Password to hash.
    * @return  PhutilOpaqueEnvelope  Hashed password, using best available
    *                                hasher.
    * @task hashing
    */
   public static function generateNewPasswordHash(
     PhutilOpaqueEnvelope $password) {
     $hasher = self::getBestHasher();
     return $hasher->getPasswordHashForStorage($password);
   }
 
 
   /**
    * Compare a password to a stored hash.
    *
-   * @param   PhutilOpaqueEnvelope  Password to compare.
-   * @param   PhutilOpaqueEnvelope  Stored password hash.
+   * @param   PhutilOpaqueEnvelope  $password Password to compare.
+   * @param   PhutilOpaqueEnvelope  $hash Stored password hash.
    * @return  bool                  True if the passwords match.
    * @task hashing
    */
   public static function comparePassword(
     PhutilOpaqueEnvelope $password,
     PhutilOpaqueEnvelope $hash) {
 
     $hasher = self::getHasherForHash($hash);
     $parts = self::parseHashFromStorage($hash);
 
     return $hasher->verifyPassword($password, $parts['hash']);
   }
 
 
   /**
    * Get the human-readable algorithm name for a given hash.
    *
-   * @param   PhutilOpaqueEnvelope  Storage hash.
+   * @param   PhutilOpaqueEnvelope  $hash Storage hash.
    * @return  string                Human-readable algorithm name.
    */
   public static function getCurrentAlgorithmName(PhutilOpaqueEnvelope $hash) {
     $raw_hash = $hash->openEnvelope();
     if (!strlen($raw_hash)) {
       return pht('None');
     }
 
     try {
       $current_hasher = self::getHasherForHash($hash);
       return $current_hasher->getHumanReadableName();
     } catch (Exception $ex) {
       $info = self::parseHashFromStorage($hash);
       $name = $info['name'];
       return pht('Unknown ("%s")', $name);
     }
   }
 
 
   /**
    * Get the human-readable algorithm name for the best available hash.
    *
    * @return  string                Human-readable name for best hash.
    */
   public static function getBestAlgorithmName() {
     try {
       $best_hasher = self::getBestHasher();
       return $best_hasher->getHumanReadableName();
     } catch (Exception $ex) {
       return pht('Unknown');
     }
   }
 
 }
diff --git a/src/view/AphrontView.php b/src/view/AphrontView.php
index 669742f29b..f1773c7579 100644
--- a/src/view/AphrontView.php
+++ b/src/view/AphrontView.php
@@ -1,225 +1,225 @@
 <?php
 
 /**
  * @task children   Managing Children
  */
 abstract class AphrontView extends Phobject
   implements PhutilSafeHTMLProducerInterface {
 
   private $viewer;
   protected $children = array();
 
 
 /* -(  Configuration  )------------------------------------------------------ */
 
 
   /**
    * Set the user viewing this element.
    *
-   * @param PhabricatorUser Viewing user.
+   * @param PhabricatorUser $viewer Viewing user.
    * @return this
    */
   public function setViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
 
   /**
    * Get the user viewing this element.
    *
    * Throws an exception if no viewer has been set.
    *
    * @return PhabricatorUser Viewing user.
    */
   public function getViewer() {
     if (!$this->viewer) {
       throw new PhutilInvalidStateException('setViewer');
     }
 
     return $this->viewer;
   }
 
 
   /**
    * Test if a viewer has been set on this element.
    *
    * @return bool True if a viewer is available.
    */
   public function hasViewer() {
     return (bool)$this->viewer;
   }
 
 
   /**
    * Deprecated, use @{method:setViewer}.
    *
    * @task config
    * @deprecated
    */
   public function setUser(PhabricatorUser $user) {
     return $this->setViewer($user);
   }
 
 
   /**
    * Deprecated, use @{method:getViewer}.
    *
    * @task config
    * @deprecated
    */
   protected function getUser() {
     if (!$this->hasViewer()) {
       return null;
     }
     return $this->getViewer();
   }
 
 
 /* -(  Managing Children  )-------------------------------------------------- */
 
 
   /**
    * Test if this View accepts children.
    *
    * By default, views accept children, but subclases may override this method
    * to prevent children from being appended. Doing so will cause
    * @{method:appendChild} to throw exceptions instead of appending children.
    *
    * @return bool   True if the View should accept children.
    * @task children
    */
   protected function canAppendChild() {
     return true;
   }
 
 
   /**
    * Append a child to the list of children.
    *
    * This method will only work if the view supports children, which is
    * determined by @{method:canAppendChild}.
    *
-   * @param  wild   Something renderable.
+   * @param  wild  $child Something renderable.
    * @return this
    */
   final public function appendChild($child) {
     if (!$this->canAppendChild()) {
       $class = get_class($this);
       throw new Exception(
         pht("View '%s' does not support children.", $class));
     }
 
     $this->children[] = $child;
 
     return $this;
   }
 
 
   /**
    * Produce children for rendering.
    *
    * Historically, this method reduced children to a string representation,
    * but it no longer does.
    *
    * @return wild Renderable children.
    * @task
    */
   final protected function renderChildren() {
     return $this->children;
   }
 
 
   /**
    * Test if an element has no children.
    *
    * @return bool True if this element has children.
    * @task children
    */
   final public function hasChildren() {
     if ($this->children) {
       $this->children = $this->reduceChildren($this->children);
     }
     return (bool)$this->children;
   }
 
 
   /**
    * Reduce effectively-empty lists of children to be actually empty. This
    * recursively removes `null`, `''`, and `array()` from the list of children
    * so that @{method:hasChildren} can more effectively align with expectations.
    *
    * NOTE: Because View children are not rendered, a View which renders down
    * to nothing will not be reduced by this method.
    *
-   * @param   list<wild>  Renderable children.
+   * @param   list<wild>  $children Renderable children.
    * @return  list<wild>  Reduced list of children.
    * @task children
    */
   private function reduceChildren(array $children) {
     foreach ($children as $key => $child) {
       if ($child === null) {
         unset($children[$key]);
       } else if ($child === '') {
         unset($children[$key]);
       } else if (is_array($child)) {
         $child = $this->reduceChildren($child);
         if ($child) {
           $children[$key] = $child;
         } else {
           unset($children[$key]);
         }
       }
     }
     return $children;
   }
 
   public function getDefaultResourceSource() {
     return 'phabricator';
   }
 
   public function requireResource($symbol) {
     $response = CelerityAPI::getStaticResourceResponse();
     $response->requireResource($symbol, $this->getDefaultResourceSource());
     return $this;
   }
 
   public function initBehavior($name, $config = array()) {
     Javelin::initBehavior(
       $name,
       $config,
       $this->getDefaultResourceSource());
     return $this;
   }
 
 
 /* -(  Rendering  )---------------------------------------------------------- */
 
 
   /**
    * Inconsistent, unreliable pre-rendering hook.
    *
    * This hook //may// fire before views render. It is not fired reliably, and
    * may fire multiple times.
    *
    * If it does fire, views might use it to register data for later loads, but
    * almost no datasources support this now; this is currently only useful for
    * tokenizers. This mechanism might eventually see wider support or might be
    * removed.
    */
   public function willRender() {
     return;
   }
 
 
   abstract public function render();
 
 
 /* -(  PhutilSafeHTMLProducerInterface  )------------------------------------ */
 
 
   public function producePhutilSafeHTML() {
     return $this->render();
   }
 
 }
diff --git a/src/view/control/AphrontTableView.php b/src/view/control/AphrontTableView.php
index ab1f6be0ed..697e895c3d 100644
--- a/src/view/control/AphrontTableView.php
+++ b/src/view/control/AphrontTableView.php
@@ -1,414 +1,414 @@
 <?php
 
 final class AphrontTableView extends AphrontView {
 
   protected $data;
   protected $headers;
   protected $shortHeaders = array();
   protected $rowClasses = array();
   protected $columnClasses = array();
   protected $cellClasses = array();
   protected $zebraStripes = true;
   protected $noDataString;
   protected $className;
   protected $notice;
   protected $columnVisibility = array();
   private $deviceVisibility = array();
 
   private $columnWidths = array();
 
   protected $sortURI;
   protected $sortParam;
   protected $sortSelected;
   protected $sortReverse;
   protected $sortValues = array();
   private $deviceReadyTable;
 
   private $rowDividers = array();
 
   public function __construct(array $data) {
     $this->data = $data;
   }
 
   public function setHeaders(array $headers) {
     $this->headers = $headers;
     return $this;
   }
 
   public function setColumnClasses(array $column_classes) {
     $this->columnClasses = $column_classes;
     return $this;
   }
 
   public function setRowClasses(array $row_classes) {
     $this->rowClasses = $row_classes;
     return $this;
   }
 
   public function setCellClasses(array $cell_classes) {
     $this->cellClasses = $cell_classes;
     return $this;
   }
 
   public function setColumnWidths(array $widths) {
     $this->columnWidths = $widths;
     return $this;
   }
 
   public function setRowDividers(array $dividers) {
     $this->rowDividers = $dividers;
     return $this;
   }
 
   public function setNoDataString($no_data_string) {
     $this->noDataString = $no_data_string;
     return $this;
   }
 
   public function setClassName($class_name) {
     $this->className = $class_name;
     return $this;
   }
 
   public function setNotice($notice) {
     $this->notice = $notice;
     return $this;
   }
 
   public function setZebraStripes($zebra_stripes) {
     $this->zebraStripes = $zebra_stripes;
     return $this;
   }
 
   public function setColumnVisibility(array $visibility) {
     $this->columnVisibility = $visibility;
     return $this;
   }
 
   public function setDeviceVisibility(array $device_visibility) {
     $this->deviceVisibility = $device_visibility;
     return $this;
   }
 
   public function setDeviceReadyTable($ready) {
     $this->deviceReadyTable = $ready;
     return $this;
   }
 
   public function setShortHeaders(array $short_headers) {
     $this->shortHeaders = $short_headers;
     return $this;
   }
 
   /**
    * Parse a sorting parameter:
    *
    *   list($sort, $reverse) = AphrontTableView::parseSortParam($sort_param);
    *
-   * @param string  Sort request parameter.
+   * @param string  $sort Sort request parameter.
    * @return pair   Sort value, sort direction.
    */
   public static function parseSort($sort) {
     return array(ltrim($sort, '-'), preg_match('/^-/', $sort));
   }
 
   public function makeSortable(
     PhutilURI $base_uri,
     $param,
     $selected,
     $reverse,
     array $sort_values) {
 
     $this->sortURI        = $base_uri;
     $this->sortParam      = $param;
     $this->sortSelected   = $selected;
     $this->sortReverse    = $reverse;
     $this->sortValues     = array_values($sort_values);
 
     return $this;
   }
 
   public function render() {
     require_celerity_resource('aphront-table-view-css');
 
     $table = array();
 
     $col_classes = array();
     foreach ($this->columnClasses as $key => $class) {
       if (phutil_nonempty_string($class)) {
         $col_classes[] = $class;
       } else {
         $col_classes[] = null;
       }
     }
 
     $visibility = array_values($this->columnVisibility);
     $device_visibility = array_values($this->deviceVisibility);
 
     $column_widths = $this->columnWidths;
 
     $headers = $this->headers;
     $short_headers = $this->shortHeaders;
     $sort_values = $this->sortValues;
     if ($headers) {
       while (count($headers) > count($visibility)) {
         $visibility[] = true;
       }
       while (count($headers) > count($device_visibility)) {
         $device_visibility[] = true;
       }
       while (count($headers) > count($short_headers)) {
         $short_headers[] = null;
       }
       while (count($headers) > count($sort_values)) {
         $sort_values[] = null;
       }
 
       $tr = array();
       foreach ($headers as $col_num => $header) {
         if (!$visibility[$col_num]) {
           continue;
         }
 
         $classes = array();
 
         if (!empty($col_classes[$col_num])) {
           $classes[] = $col_classes[$col_num];
         }
 
         if (empty($device_visibility[$col_num])) {
           $classes[] = 'aphront-table-view-nodevice';
         }
 
         if ($sort_values[$col_num] !== null) {
           $classes[] = 'aphront-table-view-sortable';
 
           $sort_value = $sort_values[$col_num];
           $sort_glyph_class = 'aphront-table-down-sort';
           if ($sort_value == $this->sortSelected) {
             if ($this->sortReverse) {
               $sort_glyph_class = 'aphront-table-up-sort';
             } else if (!$this->sortReverse) {
               $sort_value = '-'.$sort_value;
             }
             $classes[] = 'aphront-table-view-sortable-selected';
           }
 
           $sort_glyph = phutil_tag(
             'span',
             array(
               'class' => $sort_glyph_class,
             ),
             '');
 
           $header = phutil_tag(
             'a',
             array(
               'href'  => $this->sortURI->alter($this->sortParam, $sort_value),
               'class' => 'aphront-table-view-sort-link',
             ),
             array(
               $header,
               ' ',
               $sort_glyph,
             ));
         }
 
         if ($classes) {
           $class = implode(' ', $classes);
         } else {
           $class = null;
         }
 
         if ($short_headers[$col_num] !== null) {
           $header_nodevice = phutil_tag(
             'span',
             array(
               'class' => 'aphront-table-view-nodevice',
             ),
             $header);
           $header_device = phutil_tag(
             'span',
             array(
               'class' => 'aphront-table-view-device',
             ),
             $short_headers[$col_num]);
 
           $header = hsprintf('%s %s', $header_nodevice, $header_device);
         }
 
         $style = null;
         if (isset($column_widths[$col_num])) {
           $style = 'width: '.$column_widths[$col_num].';';
         }
 
         $tr[] = phutil_tag(
           'th',
           array(
             'class' => $class,
             'style' => $style,
           ),
           $header);
       }
       $table[] = phutil_tag('tr', array(), $tr);
     }
 
     foreach ($col_classes as $key => $value) {
 
       if (isset($sort_values[$key]) &&
           ($sort_values[$key] == $this->sortSelected)) {
         $value = trim($value.' sorted-column');
       }
 
       if ($value !== null) {
         $col_classes[$key] = $value;
       }
     }
 
     $dividers = $this->rowDividers;
 
     $data = $this->data;
     if ($data) {
       $row_num = 0;
       $row_idx = 0;
       foreach ($data as $row) {
         $is_divider = !empty($dividers[$row_num]);
 
         $row_size = count($row);
         while (count($row) > count($col_classes)) {
           $col_classes[] = null;
         }
         while (count($row) > count($visibility)) {
           $visibility[] = true;
         }
         while (count($row) > count($device_visibility)) {
           $device_visibility[] = true;
         }
         $tr = array();
         // NOTE: Use of a separate column counter is to allow this to work
         // correctly if the row data has string or non-sequential keys.
         $col_num = 0;
         foreach ($row as $value) {
           if (!$visibility[$col_num]) {
             ++$col_num;
             continue;
           }
           $class = $col_classes[$col_num];
           if (empty($device_visibility[$col_num])) {
             $class = trim($class.' aphront-table-view-nodevice');
           }
           if (!empty($this->cellClasses[$row_num][$col_num])) {
             $class = trim($class.' '.$this->cellClasses[$row_num][$col_num]);
           }
 
           if ($is_divider) {
             $tr[] = phutil_tag(
               'td',
               array(
                 'class' => 'row-divider',
                 'colspan' => count($visibility),
               ),
               $value);
             $row_idx = -1;
             break;
           }
 
           $tr[] = phutil_tag(
             'td',
             array(
               'class' => $class,
             ),
             $value);
           ++$col_num;
         }
 
         $class = idx($this->rowClasses, $row_num);
         if ($this->zebraStripes && ($row_idx % 2)) {
           if ($class !== null) {
             $class = 'alt alt-'.$class;
           } else {
             $class = 'alt';
           }
         }
 
         $table[] = phutil_tag('tr', array('class' => $class), $tr);
         ++$row_num;
         ++$row_idx;
       }
     } else {
       $colspan = max(count(array_filter($visibility)), 1);
       $table[] = phutil_tag(
         'tr',
         array('class' => 'no-data'),
         phutil_tag(
           'td',
           array('colspan' => $colspan),
           coalesce($this->noDataString, pht('No data available.'))));
     }
 
     $classes = array();
     $classes[] = 'aphront-table-view';
     if ($this->className !== null) {
       $classes[] = $this->className;
     }
 
     if ($this->deviceReadyTable) {
       $classes[] = 'aphront-table-view-device-ready';
     }
 
     if ($this->columnWidths) {
       $classes[] = 'aphront-table-view-fixed';
     }
 
     $notice = null;
     if ($this->notice) {
       $notice = phutil_tag(
         'div',
         array(
           'class' => 'aphront-table-notice',
         ),
         $this->notice);
     }
 
     $html = phutil_tag(
       'table',
       array(
         'class' => implode(' ', $classes),
       ),
       $table);
 
     return phutil_tag_div(
       'aphront-table-wrap',
       array(
         $notice,
         $html,
       ));
   }
 
   public static function renderSingleDisplayLine($line) {
 
     // TODO: Is there a cleaner way to do this? We use a relative div with
     // overflow hidden to provide the bounds, and an absolute span with
     // white-space: pre to prevent wrapping. We need to append a character
     // (&nbsp; -- nonbreaking space) afterward to give the bounds div height
     // (alternatively, we could hard-code the line height). This is gross but
     // it's not clear that there's a better approach.
 
     return phutil_tag(
       'div',
       array(
         'class' => 'single-display-line-bounds',
       ),
       array(
         phutil_tag(
           'span',
           array(
             'class' => 'single-display-line-content',
           ),
           $line),
         "\xC2\xA0",
       ));
   }
 
 
 }
diff --git a/src/view/form/AphrontFormView.php b/src/view/form/AphrontFormView.php
index 3022a91f9c..de1143ab85 100644
--- a/src/view/form/AphrontFormView.php
+++ b/src/view/form/AphrontFormView.php
@@ -1,180 +1,180 @@
 <?php
 
 final class AphrontFormView extends AphrontView {
 
   private $action;
   private $method = 'POST';
   private $header;
   private $data = array();
   private $encType;
   private $workflow;
   private $id;
   private $sigils = array();
   private $metadata;
   private $controls = array();
   private $fullWidth = false;
   private $classes = array();
 
   public function setMetadata($metadata) {
     $this->metadata = $metadata;
     return $this;
   }
 
   public function getMetadata() {
     return $this->metadata;
   }
 
   public function setID($id) {
     $this->id = $id;
     return $this;
   }
 
   public function setAction($action) {
     $this->action = $action;
     return $this;
   }
 
   public function setMethod($method) {
     $this->method = $method;
     return $this;
   }
 
   public function setEncType($enc_type) {
     $this->encType = $enc_type;
     return $this;
   }
 
   public function addHiddenInput($key, $value) {
     $this->data[$key] = $value;
     return $this;
   }
 
   public function setWorkflow($workflow) {
     $this->workflow = $workflow;
     return $this;
   }
 
   public function addSigil($sigil) {
     $this->sigils[] = $sigil;
     return $this;
   }
 
   public function addClass($class) {
     $this->classes[] = $class;
     return $this;
   }
 
   public function setFullWidth($full_width) {
     $this->fullWidth = $full_width;
     return $this;
   }
 
   public function getFullWidth() {
     return $this->fullWidth;
   }
 
   public function appendInstructions($text) {
     return $this->appendChild(
       phutil_tag(
         'div',
         array(
           'class' => 'aphront-form-instructions',
         ),
         $text));
   }
 
   public function appendRemarkupInstructions($remarkup) {
     $view = $this->newInstructionsRemarkupView($remarkup);
     return $this->appendInstructions($view);
   }
 
   public function newInstructionsRemarkupView($remarkup) {
     $viewer = $this->getViewer();
     $view = new PHUIRemarkupView($viewer, $remarkup);
 
     $view->setRemarkupOptions(
       array(
         PHUIRemarkupView::OPTION_PRESERVE_LINEBREAKS => false,
       ));
 
     return $view;
   }
 
   public function buildLayoutView() {
     foreach ($this->controls as $control) {
       $control->setViewer($this->getViewer());
       $control->willRender();
     }
 
     return id(new PHUIFormLayoutView())
       ->setFullWidth($this->getFullWidth())
       ->appendChild($this->renderDataInputs())
       ->appendChild($this->renderChildren());
   }
 
 
   /**
    * Append a control to the form.
    *
    * This method behaves like @{method:appendChild}, but it only takes
    * controls. It will propagate some information from the form to the
    * control to simplify rendering.
    *
-   * @param AphrontFormControl Control to append.
+   * @param AphrontFormControl $control Control to append.
    * @return this
    */
   public function appendControl(AphrontFormControl $control) {
     $this->controls[] = $control;
     return $this->appendChild($control);
   }
 
 
   public function render() {
     require_celerity_resource('phui-form-view-css');
 
     $layout = $this->buildLayoutView();
 
     if (!$this->hasViewer()) {
       throw new Exception(
         pht(
           'You must pass the user to %s.',
           __CLASS__));
     }
 
     $sigils = $this->sigils;
     if ($this->workflow) {
       $sigils[] = 'workflow';
     }
 
     return phabricator_form(
       $this->getViewer(),
       array(
         'class'   => implode(' ', $this->classes),
         'action'  => $this->action,
         'method'  => $this->method,
         'enctype' => $this->encType,
         'sigil'   => $sigils ? implode(' ', $sigils) : null,
         'meta'    => $this->metadata,
         'id'      => $this->id,
       ),
       $layout->render());
   }
 
   private function renderDataInputs() {
     $inputs = array();
     foreach ($this->data as $key => $value) {
       if ($value === null) {
         continue;
       }
       $inputs[] = phutil_tag(
         'input',
         array(
           'type'  => 'hidden',
           'name'  => $key,
           'value' => $value,
         ));
     }
     return $inputs;
   }
 
 }
diff --git a/src/view/form/control/AphrontFormControl.php b/src/view/form/control/AphrontFormControl.php
index 83e0207241..af317d257c 100644
--- a/src/view/form/control/AphrontFormControl.php
+++ b/src/view/form/control/AphrontFormControl.php
@@ -1,259 +1,259 @@
 <?php
 
 abstract class AphrontFormControl extends AphrontView {
 
   private $label;
   private $caption;
   private $error;
   private $name;
   private $value;
   private $disabled;
   private $id;
   private $controlID;
   private $controlStyle;
   private $required;
   private $hidden;
   private $classes;
 
   public function setHidden($hidden) {
     $this->hidden = $hidden;
     return $this;
   }
 
   public function setID($id) {
     $this->id = $id;
     return $this;
   }
 
   public function getID() {
     return $this->id;
   }
 
   public function setControlID($control_id) {
     $this->controlID = $control_id;
     return $this;
   }
 
   public function getControlID() {
     return $this->controlID;
   }
 
   public function setControlStyle($control_style) {
     $this->controlStyle = $control_style;
     return $this;
   }
 
   public function getControlStyle() {
     return $this->controlStyle;
   }
 
   public function setLabel($label) {
     $this->label = $label;
     return $this;
   }
 
   public function getLabel() {
     return $this->label;
   }
 
   /**
    * Set the Caption
    * The Caption shows a tip usually nearby the related input field.
-   * @param string|PhutilSafeHTML|null
+   * @param string|PhutilSafeHTML|null $caption
    * @return self
    */
   public function setCaption($caption) {
     $this->caption = $caption;
     return $this;
   }
 
   /**
    * Get the Caption
    * The Caption shows a tip usually nearby the related input field.
    * @return string|PhutilSafeHTML|null
    */
   public function getCaption() {
     return $this->caption;
   }
 
   public function setError($error) {
     $this->error = $error;
     return $this;
   }
 
   public function getError() {
     return $this->error;
   }
 
   public function setName($name) {
     $this->name = $name;
     return $this;
   }
 
   public function getName() {
     return $this->name;
   }
 
   public function setValue($value) {
     $this->value = $value;
     return $this;
   }
 
   public function getValue() {
     return $this->value;
   }
 
   public function isValid() {
     if ($this->error && $this->error !== true) {
       return false;
     }
 
     if ($this->isRequired() && $this->isEmpty()) {
       return false;
     }
 
     return true;
   }
 
   public function isRequired() {
     return $this->required;
   }
 
   public function isEmpty() {
     return !strlen($this->getValue());
   }
 
   public function getSerializedValue() {
     return $this->getValue();
   }
 
   public function readSerializedValue($value) {
     $this->setValue($value);
     return $this;
   }
 
   public function readValueFromRequest(AphrontRequest $request) {
     $this->setValue($request->getStr($this->getName()));
     return $this;
   }
 
   public function readValueFromDictionary(array $dictionary) {
     $this->setValue(idx($dictionary, $this->getName()));
     return $this;
   }
 
   public function setDisabled($disabled) {
     $this->disabled = $disabled;
     return $this;
   }
 
   public function getDisabled() {
     return $this->disabled;
   }
 
   abstract protected function renderInput();
   abstract protected function getCustomControlClass();
 
   protected function shouldRender() {
     return true;
   }
 
   public function addClass($class) {
     $this->classes[] = $class;
     return $this;
   }
 
   final public function render() {
     if (!$this->shouldRender()) {
       return null;
     }
 
     $custom_class = $this->getCustomControlClass();
 
     // If we don't have an ID yet, assign an automatic one so we can associate
     // the label with the control. This allows assistive technologies to read
     // form labels.
     if (!$this->getID()) {
       $this->setID(celerity_generate_unique_node_id());
     }
 
     $input = phutil_tag(
       'div',
       array('class' => 'aphront-form-input'),
       $this->renderInput());
 
     $error = null;
     if ($this->getError()) {
       $error = $this->getError();
       if ($error === true) {
         $error = phutil_tag(
           'span',
           array('class' => 'aphront-form-error aphront-form-required'),
           pht('Required'));
       } else {
         $error = phutil_tag(
           'span',
           array('class' => 'aphront-form-error'),
           $error);
       }
     }
 
     if (phutil_nonempty_string($this->getLabel())) {
       $label = phutil_tag(
         'label',
         array(
           'class' => 'aphront-form-label',
           'for' => $this->getID(),
         ),
         array(
           $this->getLabel(),
           $error,
         ));
     } else {
       $label = null;
       $custom_class .= ' aphront-form-control-nolabel';
     }
 
     // The Caption can be stuff like PhutilSafeHTML and other objects that
     // can be casted to a string. After this cast we have never null.
     $has_caption = phutil_string_cast($this->getCaption()) !== '';
 
     if ($has_caption) {
       $caption = phutil_tag(
         'div',
         array('class' => 'aphront-form-caption'),
         $this->getCaption());
     } else {
       $caption = null;
     }
 
     $classes = array();
     $classes[] = 'aphront-form-control';
     $classes[] = 'grouped';
     $classes[] = $custom_class;
     if ($this->classes) {
       foreach ($this->classes as $class) {
         $classes[] = $class;
       }
     }
 
     $style = $this->controlStyle;
     if ($this->hidden) {
       $style = 'display: none; '.$style;
     }
 
     return phutil_tag(
       'div',
       array(
         'class' => implode(' ', $classes),
         'id' => $this->controlID,
         'style' => $style,
       ),
       array(
         $label,
         $error,
         $input,
         $caption,
       ));
   }
 }
diff --git a/src/view/page/PhabricatorStandardPageView.php b/src/view/page/PhabricatorStandardPageView.php
index eb7b775f0f..84581e991c 100644
--- a/src/view/page/PhabricatorStandardPageView.php
+++ b/src/view/page/PhabricatorStandardPageView.php
@@ -1,928 +1,928 @@
 <?php
 
 /**
  * This is a standard Phabricator page with menus, Javelin, DarkConsole, and
  * basic styles.
  */
 final class PhabricatorStandardPageView extends PhabricatorBarePageView
   implements AphrontResponseProducerInterface {
 
   private $baseURI;
   private $applicationName;
   private $glyph;
   private $menuContent;
   private $showChrome = true;
   private $classes = array();
   private $disableConsole;
   private $pageObjects = array();
   private $applicationMenu;
   private $showFooter = true;
   private $showDurableColumn = true;
   private $quicksandConfig = array();
   private $tabs;
   private $crumbs;
   private $navigation;
   private $footer;
   private $headItems = array();
 
   public function setShowFooter($show_footer) {
     $this->showFooter = $show_footer;
     return $this;
   }
 
   public function getShowFooter() {
     return $this->showFooter;
   }
 
   public function setApplicationName($application_name) {
     $this->applicationName = $application_name;
     return $this;
   }
 
   public function setDisableConsole($disable) {
     $this->disableConsole = $disable;
     return $this;
   }
 
   public function getApplicationName() {
     return $this->applicationName;
   }
 
   public function setBaseURI($base_uri) {
     $this->baseURI = $base_uri;
     return $this;
   }
 
   public function getBaseURI() {
     return $this->baseURI;
   }
 
   public function setShowChrome($show_chrome) {
     $this->showChrome = $show_chrome;
     return $this;
   }
 
   public function getShowChrome() {
     return $this->showChrome;
   }
 
   public function addClass($class) {
     $this->classes[] = $class;
     return $this;
   }
 
   public function setPageObjectPHIDs(array $phids) {
     $this->pageObjects = $phids;
     return $this;
   }
 
   public function setShowDurableColumn($show) {
     $this->showDurableColumn = $show;
     return $this;
   }
 
   public function getShowDurableColumn() {
     $request = $this->getRequest();
     if (!$request) {
       return false;
     }
 
     $viewer = $request->getUser();
     if (!$viewer->isLoggedIn()) {
       return false;
     }
 
     $conpherence_installed = PhabricatorApplication::isClassInstalledForViewer(
       'PhabricatorConpherenceApplication',
       $viewer);
     if (!$conpherence_installed) {
       return false;
     }
 
     if ($this->isQuicksandBlacklistURI()) {
       return false;
     }
 
     return true;
   }
 
   private function isQuicksandBlacklistURI() {
     $request = $this->getRequest();
     if (!$request) {
       return false;
     }
 
     $patterns = $this->getQuicksandURIPatternBlacklist();
     $path = $request->getRequestURI()->getPath();
     foreach ($patterns as $pattern) {
       if (preg_match('(^'.$pattern.'$)', $path)) {
         return true;
       }
     }
     return false;
   }
 
   public function getDurableColumnVisible() {
     $column_key = PhabricatorConpherenceColumnVisibleSetting::SETTINGKEY;
     return (bool)$this->getUserPreference($column_key, false);
   }
 
   public function getDurableColumnMinimize() {
     $column_key = PhabricatorConpherenceColumnMinimizeSetting::SETTINGKEY;
     return (bool)$this->getUserPreference($column_key, false);
   }
 
   public function addQuicksandConfig(array $config) {
     $this->quicksandConfig = $config + $this->quicksandConfig;
     return $this;
   }
 
   public function getQuicksandConfig() {
     return $this->quicksandConfig;
   }
 
   public function setCrumbs(PHUICrumbsView $crumbs) {
     $this->crumbs = $crumbs;
     return $this;
   }
 
   public function getCrumbs() {
     return $this->crumbs;
   }
 
   public function setTabs(PHUIListView $tabs) {
     $tabs->setType(PHUIListView::TABBAR_LIST);
     $tabs->addClass('phabricator-standard-page-tabs');
     $this->tabs = $tabs;
     return $this;
   }
 
   public function getTabs() {
     return $this->tabs;
   }
 
   public function setNavigation(AphrontSideNavFilterView $navigation) {
     $this->navigation = $navigation;
     return $this;
   }
 
   public function getNavigation() {
     return $this->navigation;
   }
 
   public function getTitle() {
     $glyph_key = PhabricatorTitleGlyphsSetting::SETTINGKEY;
     $glyph_on = PhabricatorTitleGlyphsSetting::VALUE_TITLE_GLYPHS;
     $glyph_setting = $this->getUserPreference($glyph_key, $glyph_on);
 
     $use_glyph = ($glyph_setting == $glyph_on);
 
     $title = parent::getTitle();
 
     $prefix = null;
     if ($use_glyph) {
       $prefix = $this->getGlyph();
     } else {
       $application_name = $this->getApplicationName();
       if (strlen($application_name)) {
         $prefix = '['.$application_name.']';
       }
     }
 
     if (phutil_nonempty_string($prefix)) {
       $title = $prefix.' '.$title;
     }
 
     return $title;
   }
 
 
   protected function willRenderPage() {
     $footer = $this->renderFooter();
 
     // NOTE: A cleaner solution would be to let body layout elements implement
     // some kind of "LayoutInterface" so content can be embedded inside frames,
     // but there's only really one use case for this for now.
     $children = $this->renderChildren();
     if ($children) {
       $layout = head($children);
       if ($layout instanceof PHUIFormationView) {
         $layout->setFooter($footer);
         $footer = null;
       }
     }
 
     $this->footer = $footer;
 
     parent::willRenderPage();
 
     if (!$this->getRequest()) {
       throw new Exception(
         pht(
           'You must set the %s to render a %s.',
           'Request',
           __CLASS__));
     }
 
     $console = $this->getConsole();
 
     require_celerity_resource('phabricator-core-css');
     require_celerity_resource('phabricator-zindex-css');
     require_celerity_resource('phui-button-css');
     require_celerity_resource('phui-spacing-css');
     require_celerity_resource('phui-form-css');
     require_celerity_resource('phabricator-standard-page-view');
     require_celerity_resource('conpherence-durable-column-view');
     require_celerity_resource('font-lato');
 
     Javelin::initBehavior('workflow', array());
 
     $request = $this->getRequest();
     $user = null;
     if ($request) {
       $user = $request->getUser();
     }
 
     if ($user) {
       if ($user->isUserActivated()) {
         $offset = $user->getTimeZoneOffset();
 
         $ignore_key = PhabricatorTimezoneIgnoreOffsetSetting::SETTINGKEY;
         $ignore = $user->getUserSetting($ignore_key);
 
         Javelin::initBehavior(
           'detect-timezone',
           array(
             'offset' => $offset,
             'uri' => '/settings/timezone/',
             'message' => pht(
               'Your browser timezone setting differs from the timezone '.
               'setting in your profile, click to reconcile.'),
             'ignoreKey' => $ignore_key,
             'ignore' => $ignore,
           ));
 
         if ($user->getIsAdmin()) {
           $server_https = $request->isHTTPS();
           $server_protocol = $server_https ? 'HTTPS' : 'HTTP';
           $client_protocol = $server_https ? 'HTTP' : 'HTTPS';
 
           $doc_name = 'Configuring a Preamble Script';
           $doc_href = PhabricatorEnv::getDoclink($doc_name);
 
           Javelin::initBehavior(
             'setup-check-https',
             array(
               'server_https' => $server_https,
               'doc_name' => pht('See Documentation'),
               'doc_href' => $doc_href,
               'message' => pht(
                 'This server thinks you are using %s, but your '.
                 'client is convinced that it is using %s. This is a serious '.
                 'misconfiguration with subtle, but significant, consequences.',
                 $server_protocol, $client_protocol),
             ));
         }
       }
 
       Javelin::initBehavior('lightbox-attachments');
     }
 
     Javelin::initBehavior('aphront-form-disable-on-submit');
     Javelin::initBehavior('toggle-class', array());
     Javelin::initBehavior('history-install');
     Javelin::initBehavior('phabricator-gesture');
 
     $current_token = null;
     if ($user) {
       $current_token = $user->getCSRFToken();
     }
 
     Javelin::initBehavior(
       'refresh-csrf',
       array(
         'tokenName' => AphrontRequest::getCSRFTokenName(),
         'header' => AphrontRequest::getCSRFHeaderName(),
         'viaHeader' => AphrontRequest::getViaHeaderName(),
         'current'   => $current_token,
       ));
 
     Javelin::initBehavior('device');
 
     Javelin::initBehavior(
       'high-security-warning',
       $this->getHighSecurityWarningConfig());
 
     if (PhabricatorEnv::isReadOnly()) {
       Javelin::initBehavior(
         'read-only-warning',
         array(
           'message' => PhabricatorEnv::getReadOnlyMessage(),
           'uri' => PhabricatorEnv::getReadOnlyURI(),
         ));
     }
 
     // If we aren't showing the page chrome, skip rendering DarkConsole and the
     // main menu, since they won't be visible on the page.
     if (!$this->getShowChrome()) {
       return;
     }
 
     if ($console) {
       require_celerity_resource('aphront-dark-console-css');
 
       $headers = array();
       if (DarkConsoleXHProfPluginAPI::isProfilerStarted()) {
         $headers[DarkConsoleXHProfPluginAPI::getProfilerHeader()] = 'page';
       }
       if (DarkConsoleServicesPlugin::isQueryAnalyzerRequested()) {
         $headers[DarkConsoleServicesPlugin::getQueryAnalyzerHeader()] = true;
       }
 
       Javelin::initBehavior(
         'dark-console',
         $this->getConsoleConfig());
     }
 
     if ($user) {
       $viewer = $user;
     } else {
       $viewer = new PhabricatorUser();
     }
 
     $menu = id(new PhabricatorMainMenuView())
       ->setUser($viewer);
 
     if ($this->getController()) {
       $menu->setController($this->getController());
     }
 
     $application_menu = $this->applicationMenu;
     if ($application_menu) {
       if ($application_menu instanceof PHUIApplicationMenuView) {
         $crumbs = $this->getCrumbs();
         if ($crumbs) {
           $application_menu->setCrumbs($crumbs);
         }
 
         $application_menu = $application_menu->buildListView();
       }
 
       $menu->setApplicationMenu($application_menu);
     }
 
 
     $this->menuContent = $menu->render();
   }
 
 
   /**
    * Insert a HTML element into <head> of the page to render.
    *
-   * @param PhutilSafeHTML HTML header to add
+   * @param PhutilSafeHTML $html HTML header to add
    */
   public function addHeadItem($html) {
     if ($html instanceof PhutilSafeHTML) {
       $this->headItems[] = $html;
     }
   }
 
   protected function getHead() {
     $monospaced = null;
 
     $request = $this->getRequest();
     if ($request) {
       $user = $request->getUser();
       if ($user) {
         $monospaced = $user->getUserSetting(
           PhabricatorMonospacedFontSetting::SETTINGKEY);
       }
     }
 
     $response = CelerityAPI::getStaticResourceResponse();
 
     $font_css = null;
     if (!empty($monospaced)) {
       // We can't print this normally because escaping quotation marks will
       // break the CSS. Instead, filter it strictly and then mark it as safe.
       $monospaced = new PhutilSafeHTML(
         PhabricatorMonospacedFontSetting::filterMonospacedCSSRule(
           $monospaced));
 
       $font_css = hsprintf(
         '<style type="text/css">'.
         '.PhabricatorMonospaced, '.
         '.phabricator-remarkup .remarkup-code-block .remarkup-code, '.
         '.phabricator-remarkup .remarkup-monospaced '.
         '{ font: %s !important; } '.
         '</style>',
         $monospaced);
     }
 
     return hsprintf(
       '%s%s%s%s',
       parent::getHead(),
       $font_css,
       phutil_implode_html('', $this->headItems),
       $response->renderSingleResource('javelin-magical-init', 'phabricator'));
   }
 
   public function setGlyph($glyph) {
     $this->glyph = $glyph;
     return $this;
   }
 
   public function getGlyph() {
     return $this->glyph;
   }
 
   protected function willSendResponse($response) {
     $request = $this->getRequest();
     $response = parent::willSendResponse($response);
 
     $console = $request->getApplicationConfiguration()->getConsole();
 
     if ($console) {
       $response = PhutilSafeHTML::applyFunction(
         'str_replace',
         hsprintf('<darkconsole />'),
         $console->render($request),
         $response);
     }
 
     return $response;
   }
 
   protected function getBody() {
     $user = null;
     $request = $this->getRequest();
     if ($request) {
       $user = $request->getUser();
     }
 
     $header_chrome = null;
     if ($this->getShowChrome()) {
       $header_chrome = $this->menuContent;
     }
 
     $classes = array();
     $classes[] = 'main-page-frame';
     $developer_warning = null;
     if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode') &&
         DarkConsoleErrorLogPluginAPI::getErrors()) {
       $developer_warning = phutil_tag_div(
         'aphront-developer-error-callout',
         pht(
           'This page raised PHP errors. Find them in DarkConsole '.
           'or the error log.'));
     }
 
     $main_page = phutil_tag(
       'div',
       array(
         'id' => 'phabricator-standard-page',
         'class' => 'phabricator-standard-page',
       ),
       array(
         $developer_warning,
         $header_chrome,
         phutil_tag(
           'div',
           array(
             'id' => 'phabricator-standard-page-body',
             'class' => 'phabricator-standard-page-body',
           ),
           $this->renderPageBodyContent()),
       ));
 
     $durable_column = null;
     if ($this->getShowDurableColumn()) {
       $is_visible = $this->getDurableColumnVisible();
       $is_minimize = $this->getDurableColumnMinimize();
       $durable_column = id(new ConpherenceDurableColumnView())
         ->setSelectedConpherence(null)
         ->setUser($user)
         ->setQuicksandConfig($this->buildQuicksandConfig())
         ->setVisible($is_visible)
         ->setMinimize($is_minimize)
         ->setInitialLoad(true);
       if ($is_minimize) {
         $this->classes[] = 'minimize-column';
       }
     }
 
     Javelin::initBehavior('quicksand-blacklist', array(
       'patterns' => $this->getQuicksandURIPatternBlacklist(),
     ));
 
     return phutil_tag(
       'div',
       array(
         'class' => implode(' ', $classes),
         'id' => 'main-page-frame',
       ),
       array(
         $main_page,
         $durable_column,
       ));
   }
 
   private function renderPageBodyContent() {
     $console = $this->getConsole();
 
     $body = parent::getBody();
 
     $nav = $this->getNavigation();
     $tabs = $this->getTabs();
     if ($nav) {
       $crumbs = $this->getCrumbs();
       if ($crumbs) {
         $nav->setCrumbs($crumbs);
       }
       $nav->appendChild($body);
       $nav->appendFooter($this->footer);
       $content = phutil_implode_html('', array($nav->render()));
     } else {
       $content = array();
 
       $crumbs = $this->getCrumbs();
       if ($crumbs) {
         if ($this->getTabs()) {
           $crumbs->setBorder(true);
         }
         $content[] = $crumbs;
       }
 
       $tabs = $this->getTabs();
       if ($tabs) {
         $content[] = $tabs;
       }
 
       $content[] = $body;
       $content[] = $this->footer;
 
       $content = phutil_implode_html('', $content);
     }
 
     return array(
       ($console ? hsprintf('<darkconsole />') : null),
       $content,
     );
   }
 
   protected function getTail() {
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $tail = array(
       parent::getTail(),
     );
 
     $response = CelerityAPI::getStaticResourceResponse();
 
     if ($request->isHTTPS()) {
       $with_protocol = 'https';
     } else {
       $with_protocol = 'http';
     }
 
     $servers = PhabricatorNotificationServerRef::getEnabledClientServers(
       $with_protocol);
 
     if ($servers) {
       if ($user && $user->isLoggedIn()) {
         // TODO: We could tell the browser about all the servers and let it
         // do random reconnects to improve reliability.
         shuffle($servers);
         $server = head($servers);
 
         $client_uri = $server->getWebsocketURI();
 
         Javelin::initBehavior(
           'aphlict-listen',
           array(
             'websocketURI' => (string)$client_uri,
           ) + $this->buildAphlictListenConfigData());
 
         CelerityAPI::getStaticResourceResponse()
           ->addContentSecurityPolicyURI('connect-src', $client_uri);
       }
     }
 
     $tail[] = $response->renderHTMLFooter($this->getFrameable());
 
     return $tail;
   }
 
   protected function getBodyClasses() {
     $classes = array();
 
     if (!$this->getShowChrome()) {
       $classes[] = 'phabricator-chromeless-page';
     }
 
     $agent = AphrontRequest::getHTTPHeader('User-Agent');
 
     // Try to guess the device resolution based on UA strings to avoid a flash
     // of incorrectly-styled content.
     $device_guess = 'device-desktop';
     if (preg_match('@iPhone|iPod|(Android.*Chrome/[.0-9]* Mobile)@', $agent)) {
       $device_guess = 'device-phone device';
     } else if (preg_match('@iPad|(Android.*Chrome/)@', $agent)) {
       $device_guess = 'device-tablet device';
     }
 
     $classes[] = $device_guess;
 
     if (preg_match('@Windows@', $agent)) {
       $classes[] = 'platform-windows';
     } else if (preg_match('@Macintosh@', $agent)) {
       $classes[] = 'platform-mac';
     } else if (preg_match('@X11@', $agent)) {
       $classes[] = 'platform-linux';
     }
 
     if ($this->getRequest()->getStr('__print__')) {
       $classes[] = 'printable';
     }
 
     if ($this->getRequest()->getStr('__aural__')) {
       $classes[] = 'audible';
     }
 
     $classes[] = 'phui-theme-'.PhabricatorEnv::getEnvConfig('ui.header-color');
     foreach ($this->classes as $class) {
       $classes[] = $class;
     }
 
     return implode(' ', $classes);
   }
 
   private function getConsole() {
     if ($this->disableConsole) {
       return null;
     }
     return $this->getRequest()->getApplicationConfiguration()->getConsole();
   }
 
   private function getConsoleConfig() {
     $user = $this->getRequest()->getUser();
 
     $headers = array();
     if (DarkConsoleXHProfPluginAPI::isProfilerStarted()) {
       $headers[DarkConsoleXHProfPluginAPI::getProfilerHeader()] = 'page';
     }
     if (DarkConsoleServicesPlugin::isQueryAnalyzerRequested()) {
       $headers[DarkConsoleServicesPlugin::getQueryAnalyzerHeader()] = true;
     }
 
     if ($user) {
       $setting_tab = PhabricatorDarkConsoleTabSetting::SETTINGKEY;
       $setting_visible = PhabricatorDarkConsoleVisibleSetting::SETTINGKEY;
       $tab = $user->getUserSetting($setting_tab);
       $visible = $user->getUserSetting($setting_visible);
     } else {
       $tab = null;
       $visible = true;
     }
 
     return array(
       // NOTE: We use a generic label here to prevent input reflection
       // and mitigate compression attacks like BREACH. See discussion in
       // T3684.
       'uri' => pht('Main Request'),
       'selected' => $tab,
       'visible'  => $visible,
       'headers' => $headers,
     );
   }
 
   private function getHighSecurityWarningConfig() {
     $user = $this->getRequest()->getUser();
 
     $show = false;
     if ($user->hasSession()) {
       $hisec = ($user->getSession()->getHighSecurityUntil() - time());
       if ($hisec > 0) {
         $show = true;
       }
     }
 
     return array(
       'show' => $show,
       'uri' => '/auth/session/downgrade/',
       'message' => pht(
         'Your session is in high security mode. When you '.
         'finish using it, click here to leave.'),
         );
   }
 
   private function renderFooter() {
     if (!$this->getShowChrome()) {
       return null;
     }
 
     if (!$this->getShowFooter()) {
       return null;
     }
 
     $items = PhabricatorEnv::getEnvConfig('ui.footer-items');
     if (!$items) {
       return null;
     }
 
     $foot = array();
     foreach ($items as $item) {
       $name = idx($item, 'name', pht('Unnamed Footer Item'));
 
       $href = idx($item, 'href');
       if (!PhabricatorEnv::isValidURIForLink($href)) {
         $href = null;
       }
 
       if ($href !== null) {
         $tag = 'a';
       } else {
         $tag = 'span';
       }
 
       $foot[] = phutil_tag(
         $tag,
         array(
           'href' => $href,
         ),
         $name);
     }
     $foot = phutil_implode_html(" \xC2\xB7 ", $foot);
 
     return phutil_tag(
       'div',
       array(
         'class' => 'phabricator-standard-page-footer grouped',
       ),
       $foot);
   }
 
   public function renderForQuicksand() {
     parent::willRenderPage();
     $response = $this->renderPageBodyContent();
     $response = $this->willSendResponse($response);
 
     $extra_config = $this->getQuicksandConfig();
 
     return array(
       'content' => hsprintf('%s', $response),
     ) + $this->buildQuicksandConfig()
       + $extra_config;
   }
 
   private function buildQuicksandConfig() {
     $viewer = $this->getRequest()->getUser();
     $controller = $this->getController();
 
     $dropdown_query = id(new AphlictDropdownDataQuery())
       ->setViewer($viewer);
     $dropdown_query->execute();
 
     $hisec_warning_config = $this->getHighSecurityWarningConfig();
 
     $console_config = null;
     $console = $this->getConsole();
     if ($console) {
       $console_config = $this->getConsoleConfig();
     }
 
     $upload_enabled = false;
     if ($controller) {
       $upload_enabled = $controller->isGlobalDragAndDropUploadEnabled();
     }
 
     $application_class = null;
     $application_search_icon = null;
     $application_help = null;
     $controller = $this->getController();
     if ($controller) {
       $application = $controller->getCurrentApplication();
       if ($application) {
         $application_class = get_class($application);
         if ($application->getApplicationSearchDocumentTypes()) {
           $application_search_icon = $application->getIcon();
         }
 
         $help_items = $application->getHelpMenuItems($viewer);
         if ($help_items) {
           $help_list = id(new PhabricatorActionListView())
             ->setViewer($viewer);
           foreach ($help_items as $help_item) {
             $help_list->addAction($help_item);
           }
           $application_help = $help_list->getDropdownMenuMetadata();
         }
       }
     }
 
     return array(
       'title' => $this->getTitle(),
       'bodyClasses' => $this->getBodyClasses(),
       'aphlictDropdownData' => array(
         $dropdown_query->getNotificationData(),
         $dropdown_query->getConpherenceData(),
       ),
       'globalDragAndDrop' => $upload_enabled,
       'hisecWarningConfig' => $hisec_warning_config,
       'consoleConfig' => $console_config,
       'applicationClass' => $application_class,
       'applicationSearchIcon' => $application_search_icon,
       'helpItems' => $application_help,
     ) + $this->buildAphlictListenConfigData();
   }
 
   private function buildAphlictListenConfigData() {
     $user = $this->getRequest()->getUser();
     $subscriptions = $this->pageObjects;
     $subscriptions[] = $user->getPHID();
 
     return array(
       'pageObjects'   => array_fill_keys($this->pageObjects, true),
       'subscriptions' => $subscriptions,
     );
   }
 
   private function getQuicksandURIPatternBlacklist() {
     $applications = PhabricatorApplication::getAllApplications();
 
     $blacklist = array();
     foreach ($applications as $application) {
       $blacklist[] = $application->getQuicksandURIPatternBlacklist();
     }
 
     // See T4340. Currently, Phortune and Auth both require pulling in external
     // Javascript (for Stripe card management and Recaptcha, respectively).
     // This can put us in a position where the user loads a page with a
     // restrictive Content-Security-Policy, then uses Quicksand to navigate to
     // a page which needs to load external scripts. For now, just blacklist
     // these entire applications since we aren't giving up anything
     // significant by doing so.
 
     $blacklist[] = array(
       '/phortune/.*',
       '/auth/.*',
     );
 
     return array_mergev($blacklist);
   }
 
   private function getUserPreference($key, $default = null) {
     $request = $this->getRequest();
     if (!$request) {
       return $default;
     }
 
     $user = $request->getUser();
     if (!$user) {
       return $default;
     }
 
     return $user->getUserSetting($key);
   }
 
   public function produceAphrontResponse() {
     $controller = $this->getController();
 
     $viewer = $this->getUser();
     if ($viewer && $viewer->getPHID()) {
       $object_phids = $this->pageObjects;
       foreach ($object_phids as $object_phid) {
         PhabricatorFeedStoryNotification::updateObjectNotificationViews(
           $viewer,
           $object_phid);
       }
     }
 
     if ($this->getRequest()->isQuicksand()) {
       $content = $this->renderForQuicksand();
       $response = id(new AphrontAjaxResponse())
         ->setContent($content);
     } else {
       // See T13247. Try to find some navigational menu items to create a
       // mobile navigation menu from.
       $application_menu = $controller->buildApplicationMenu();
       if (!$application_menu) {
         $navigation = $this->getNavigation();
         if ($navigation) {
           $application_menu = $navigation->getMenu();
         }
       }
       $this->applicationMenu = $application_menu;
 
       $content = $this->render();
 
       $response = id(new AphrontWebpageResponse())
         ->setContent($content)
         ->setFrameable($this->getFrameable());
     }
 
     return $response;
   }
 
 }
diff --git a/src/view/phui/PHUIBoxView.php b/src/view/phui/PHUIBoxView.php
index 33924379cc..2fb7f68802 100644
--- a/src/view/phui/PHUIBoxView.php
+++ b/src/view/phui/PHUIBoxView.php
@@ -1,86 +1,87 @@
 <?php
 
 final class PHUIBoxView extends AphrontTagView {
 
   private $margin = array();
   private $padding = array();
   private $border = false;
   private $color;
   private $collapsible;
 
   const BLUE = 'phui-box-blue';
   const GREY = 'phui-box-grey';
 
   public function addMargin($margin) {
     $this->margin[] = $margin;
     return $this;
   }
 
   public function addPadding($padding) {
     $this->padding[] = $padding;
     return $this;
   }
 
   public function setBorder($border) {
     $this->border = $border;
     return $this;
   }
 
   public function setColor($color) {
     $this->color = $color;
     return $this;
   }
 
   /**
    * Render PHUIBoxView as a <details> instead of a <div> HTML tag.
    * To be used for collapse/expand in combination with PHUIHeaderView.
    *
-   * @param bool True to wrap in <summary> instead of <div> HTML tag.
+   * @param bool $collapsible True to wrap in <summary> instead of <div> HTML
+   *             tag.
    */
   public function setCollapsible($collapsible) {
     $this->collapsible = $collapsible;
     return $this;
   }
 
   protected function getTagAttributes() {
     require_celerity_resource('phui-box-css');
     $outer_classes = array();
     $outer_classes[] = 'phui-box';
 
     if ($this->border) {
       $outer_classes[] = 'phui-box-border';
     }
 
     foreach ($this->margin as $margin) {
       $outer_classes[] = $margin;
     }
 
     foreach ($this->padding as $padding) {
       $outer_classes[] = $padding;
     }
 
     if ($this->color) {
       $outer_classes[] = $this->color;
     }
 
     $tag_classes = array('class' => $outer_classes);
 
     if ($this->collapsible) {
       $attribute = array('open' => ''); // expand column by default
       $tag_classes = array_merge($tag_classes, $attribute);
     }
 
     return $tag_classes;
   }
 
   protected function getTagName() {
     if ($this->collapsible) {
       return 'details';
     }
     return 'div';
   }
 
   protected function getTagContent() {
     return $this->renderChildren();
   }
 }
diff --git a/src/view/phui/PHUICrumbView.php b/src/view/phui/PHUICrumbView.php
index 1e5dad2ec6..a8c0a7e57a 100644
--- a/src/view/phui/PHUICrumbView.php
+++ b/src/view/phui/PHUICrumbView.php
@@ -1,133 +1,133 @@
 <?php
 
 final class PHUICrumbView extends AphrontView {
 
   private $name;
   private $href;
   private $icon;
   private $isLastCrumb;
   private $workflow;
   private $aural;
   private $alwaysVisible;
 
   public function setAural($aural) {
     $this->aural = $aural;
     return $this;
   }
 
   public function getAural() {
     return $this->aural;
   }
 
   /**
    * Make this crumb always visible, even on devices where it would normally
    * be hidden.
    *
-   * @param bool True to make the crumb always visible.
+   * @param bool $always_visible True to make the crumb always visible.
    * @return this
    */
   public function setAlwaysVisible($always_visible) {
     $this->alwaysVisible = $always_visible;
     return $this;
   }
 
   public function getAlwaysVisible() {
     return $this->alwaysVisible;
   }
 
   public function setWorkflow($workflow) {
     $this->workflow = $workflow;
     return $this;
   }
 
   public function setName($name) {
     $this->name = $name;
     return $this;
   }
 
   public function getName() {
     return $this->name;
   }
 
   public function setHref($href) {
     $this->href = $href;
     return $this;
   }
 
   public function setIcon($icon) {
     $this->icon = $icon;
     return $this;
   }
 
   protected function canAppendChild() {
     return false;
   }
 
   public function setIsLastCrumb($is_last_crumb) {
     $this->isLastCrumb = $is_last_crumb;
     return $this;
   }
 
   public function render() {
     $classes = array(
       'phui-crumb-view',
     );
 
     $aural = null;
     if ($this->aural !== null) {
       $aural = javelin_tag(
         'span',
         array(
           'aural' => true,
         ),
         $this->aural);
     }
 
     $icon = null;
     if ($this->icon) {
       $classes[] = 'phui-crumb-has-icon';
       $icon = id(new PHUIIconView())
         ->setIcon($this->icon);
     }
 
     // Surround the crumb name with spaces so that double clicking it only
     // selects the crumb itself.
     $name = array(' ', $this->name);
 
     $name = phutil_tag(
       'span',
       array(
         'class' => 'phui-crumb-name',
       ),
       $name);
 
     // Because of text-overflow and safari, put the second space on the
     // outside of the element.
     $name = array($name, ' ');
 
     $divider = null;
     if (!$this->isLastCrumb) {
       $divider = id(new PHUIIconView())
         ->setIcon('fa-angle-right')
         ->addClass('phui-crumb-divider')
         ->addClass('phui-crumb-view');
     } else {
       $classes[] = 'phabricator-last-crumb';
     }
 
     if ($this->getAlwaysVisible()) {
       $classes[] = 'phui-crumb-always-visible';
     }
 
     $tag = javelin_tag(
       $this->href ? 'a' : 'span',
         array(
           'sigil' => $this->workflow ? 'workflow' : null,
           'href'  => $this->href,
           'class' => implode(' ', $classes),
         ),
         array($aural, $icon, $name));
 
     return array($tag, $divider);
   }
 }
diff --git a/src/view/phui/PHUICrumbsView.php b/src/view/phui/PHUICrumbsView.php
index fb1a0ae9b4..bd202ed5a7 100644
--- a/src/view/phui/PHUICrumbsView.php
+++ b/src/view/phui/PHUICrumbsView.php
@@ -1,150 +1,150 @@
 <?php
 
 final class PHUICrumbsView extends AphrontView {
 
   private $crumbs = array();
   private $actions = array();
   private $border;
 
   protected function canAppendChild() {
     return false;
   }
 
 
   /**
    * Convenience method for adding a simple crumb with just text, or text and
    * a link.
    *
-   * @param string  Text of the crumb.
-   * @param string? Optional href for the crumb.
+   * @param string  $text Text of the crumb.
+   * @param string? $href Optional href for the crumb.
    * @return this
    */
   public function addTextCrumb($text, $href = null) {
     return $this->addCrumb(
       id(new PHUICrumbView())
         ->setName($text)
         ->setHref($href));
   }
 
   public function addCrumb(PHUICrumbView $crumb) {
     $this->crumbs[] = $crumb;
     return $this;
   }
 
   public function addAction(PHUIListItemView $action) {
     $this->actions[] = $action;
     return $this;
   }
 
   public function setBorder($border) {
     $this->border = $border;
     return $this;
   }
 
   public function getActions() {
     return $this->actions;
   }
 
   public function render() {
     require_celerity_resource('phui-crumbs-view-css');
 
     $action_view = null;
     if ($this->actions) {
       // TODO: This block of code takes "PHUIListItemView" objects and turns
       // them into some weird abomination by reading most of their properties
       // out. Some day, this workflow should render the items and CSS should
       // resytle them in place without needing a wholly separate set of
       // DOM nodes.
 
       $actions = array();
       foreach ($this->actions as $action) {
         if ($action->getType() == PHUIListItemView::TYPE_DIVIDER) {
           $actions[] = phutil_tag(
             'span',
             array(
               'class' => 'phui-crumb-action-divider',
             ));
           continue;
         }
 
         $icon = null;
         if ($action->getIcon()) {
           $icon_name = $action->getIcon();
           if ($action->getDisabled()) {
             $icon_name .= ' lightgreytext';
           }
 
           $icon = id(new PHUIIconView())
             ->setIcon($icon_name);
 
         }
 
         $action_classes = $action->getClasses();
         $action_classes[] = 'phui-crumbs-action';
 
         $name = null;
         if ($action->getName()) {
           $name = phutil_tag(
             'span',
               array(
                 'class' => 'phui-crumbs-action-name',
               ),
             $action->getName());
         } else {
           $action_classes[] = 'phui-crumbs-action-icon';
         }
 
         $action_sigils = $action->getSigils();
         if ($action->getWorkflow()) {
           $action_sigils[] = 'workflow';
         }
 
         if ($action->getDisabled()) {
           $action_classes[] = 'phui-crumbs-action-disabled';
         }
 
         $actions[] = javelin_tag(
           'a',
           array(
             'href' => $action->getHref(),
             'class' => implode(' ', $action_classes),
             'sigil' => implode(' ', $action_sigils),
             'style' => $action->getStyle(),
             'meta' => $action->getMetadata(),
           ),
           array(
             $icon,
             $name,
           ));
       }
 
       $action_view = phutil_tag(
         'div',
         array(
           'class' => 'phui-crumbs-actions',
         ),
         $actions);
     }
 
     if ($this->crumbs) {
       last($this->crumbs)->setIsLastCrumb(true);
     }
 
     $classes = array();
     $classes[] = 'phui-crumbs-view';
     if ($this->border) {
       $classes[] = 'phui-crumbs-border';
     }
 
     return phutil_tag(
       'div',
       array(
         'class' => implode(' ', $classes),
       ),
       array(
         $action_view,
         $this->crumbs,
       ));
   }
 
 }
diff --git a/src/view/phui/PHUIHeaderView.php b/src/view/phui/PHUIHeaderView.php
index f1952b4843..8b8774c4cf 100644
--- a/src/view/phui/PHUIHeaderView.php
+++ b/src/view/phui/PHUIHeaderView.php
@@ -1,539 +1,540 @@
 <?php
 
 final class PHUIHeaderView extends AphrontTagView {
 
   const PROPERTY_STATUS = 1;
 
   private $header;
   private $tags = array();
   private $image;
   private $imageURL = null;
   private $imageEditURL = null;
   private $subheader;
   private $headerIcon;
   private $noBackground;
   private $bleedHeader;
   private $profileHeader;
   private $tall;
   private $properties = array();
   private $actionLinks = array();
   private $buttonBar = null;
   private $policyObject;
   private $epoch;
   private $actionItems = array();
   private $href;
   private $actionList;
   private $actionListID;
   private $collapsible;
 
   public function setHeader($header) {
     $this->header = $header;
     return $this;
   }
 
   public function setNoBackground($nada) {
     $this->noBackground = $nada;
     return $this;
   }
 
   public function setTall($tall) {
     $this->tall = $tall;
     return $this;
   }
 
   public function addTag(PHUITagView $tag) {
     $this->tags[] = $tag;
     return $this;
   }
 
   public function setImage($uri) {
     $this->image = $uri;
     return $this;
   }
 
   public function setImageURL($url) {
     $this->imageURL = $url;
     return $this;
   }
 
   public function setImageEditURL($url) {
     $this->imageEditURL = $url;
     return $this;
   }
 
   public function setSubheader($subheader) {
     $this->subheader = $subheader;
     return $this;
   }
 
   public function setBleedHeader($bleed) {
     $this->bleedHeader = $bleed;
     return $this;
   }
 
   public function setProfileHeader($bighead) {
     $this->profileHeader = $bighead;
     return $this;
   }
 
   public function setHeaderIcon($icon) {
     $this->headerIcon = $icon;
     return $this;
   }
 
   public function setActionList(PhabricatorActionListView $list) {
     $this->actionList = $list;
     return $this;
   }
 
   public function setActionListID($action_list_id) {
     $this->actionListID = $action_list_id;
     return $this;
   }
 
   /**
    * Render PHUIHeaderView as a <summary> instead of a <div> HTML tag.
    * To be used for collapse/expand in combination with PHUIBoxView.
    *
-   * @param bool True to wrap in <summary> instead of <div> HTML tag.
+   * @param bool $collapsible True to wrap in <summary> instead of <div> HTML
+   *   tag.
    */
   public function setCollapsible($collapsible) {
     $this->collapsible = $collapsible;
     return $this;
   }
 
   public function setPolicyObject(PhabricatorPolicyInterface $object) {
     $this->policyObject = $object;
     return $this;
   }
 
   public function addProperty($property, $value) {
     $this->properties[$property] = $value;
     return $this;
   }
 
   public function addActionLink(PHUIButtonView $button) {
     $this->actionLinks[] = $button;
     return $this;
   }
 
   public function addActionItem($action) {
     $this->actionItems[] = $action;
     return $this;
   }
 
   public function setButtonBar(PHUIButtonBarView $bb) {
     $this->buttonBar = $bb;
     return $this;
   }
 
   public function setStatus($icon, $color, $name) {
 
     // TODO: Normalize "closed/archived" to constants.
     if ($color == 'dark') {
       $color = PHUITagView::COLOR_INDIGO;
     }
 
     $tag = id(new PHUITagView())
       ->setName($name)
       ->setIcon($icon)
       ->setColor($color)
       ->setType(PHUITagView::TYPE_SHADE);
 
     return $this->addProperty(self::PROPERTY_STATUS, $tag);
   }
 
   public function setEpoch($epoch) {
     $age = time() - $epoch;
     $age = floor($age / (60 * 60 * 24));
     if ($age < 1) {
       $when = pht('Today');
     } else if ($age == 1) {
       $when = pht('Yesterday');
     } else {
       $when = pht('%s Day(s) Ago', new PhutilNumber($age));
     }
 
     $this->setStatus('fa-clock-o bluegrey', null, pht('Updated %s', $when));
     return $this;
   }
 
   public function setHref($href) {
     $this->href = $href;
     return $this;
   }
 
   public function getHref() {
     return $this->href;
   }
 
   protected function getTagName() {
     if ($this->collapsible) {
       return 'summary';
     }
     return 'div';
   }
 
   protected function getTagAttributes() {
     require_celerity_resource('phui-header-view-css');
 
     $classes = array();
     $classes[] = 'phui-header-shell';
 
     if ($this->noBackground) {
       $classes[] = 'phui-header-no-background';
     }
 
     if ($this->bleedHeader) {
       $classes[] = 'phui-bleed-header';
     }
 
     if ($this->profileHeader) {
       $classes[] = 'phui-profile-header';
     }
 
     if ($this->properties || $this->policyObject ||
         $this->subheader || $this->tall) {
       $classes[] = 'phui-header-tall';
     }
 
     return array(
       'class' => $classes,
     );
   }
 
   protected function getTagContent() {
 
     if ($this->actionList || $this->actionListID) {
       $action_button = id(new PHUIButtonView())
         ->setTag('a')
         ->setText(pht('Actions'))
         ->setHref('#')
         ->setIcon('fa-bars')
         ->addClass('phui-mobile-menu');
 
       if ($this->actionList) {
         $action_button->setDropdownMenu($this->actionList);
       } else if ($this->actionListID) {
         $action_button->setDropdownMenuID($this->actionListID);
       }
 
       $this->addActionLink($action_button);
     }
 
     $image = null;
     if ($this->image) {
       $image_href = null;
       if ($this->imageURL) {
         $image_href = $this->imageURL;
       } else if ($this->imageEditURL) {
         $image_href = $this->imageEditURL;
       }
 
       $image = phutil_tag(
         'span',
         array(
           'class' => 'phui-header-image',
           'style' => 'background-image: url('.$this->image.')',
         ));
 
       if ($image_href) {
         $edit_view = null;
         if ($this->imageEditURL) {
           $edit_view = phutil_tag(
             'span',
             array(
               'class' => 'phui-header-image-edit',
             ),
             pht('Edit'));
         }
 
         $image = phutil_tag(
           'a',
           array(
             'href' => $image_href,
             'class' => 'phui-header-image-href',
           ),
           array(
             $image,
             $edit_view,
           ));
       }
     }
 
     $viewer = $this->getUser();
 
     $left = array();
     $right = array();
 
     $space_header = null;
     if ($viewer) {
       $space_header = id(new PHUISpacesNamespaceContextView())
         ->setUser($viewer)
         ->setObject($this->policyObject);
     }
 
     if ($this->actionLinks) {
       $actions = array();
       foreach ($this->actionLinks as $button) {
         if (!$button->getColor()) {
           $button->setColor(PHUIButtonView::GREY);
         }
         $button->addClass(PHUI::MARGIN_SMALL_LEFT);
         $button->addClass('phui-header-action-link');
         $actions[] = $button;
       }
       $right[] = phutil_tag(
         'div',
         array(
           'class' => 'phui-header-action-links',
         ),
         $actions);
     }
 
     if ($this->buttonBar) {
       $right[] = phutil_tag(
         'div',
         array(
           'class' => 'phui-header-action-links',
         ),
         $this->buttonBar);
     }
 
     if ($this->actionItems) {
       $action_list = array();
       if ($this->actionItems) {
         foreach ($this->actionItems as $item) {
           $action_list[] = phutil_tag(
             'li',
             array(
               'class' => 'phui-header-action-item',
             ),
             $item);
         }
       }
       $right[] = phutil_tag(
         'ul',
           array(
             'class' => 'phui-header-action-list',
           ),
           $action_list);
     }
 
     $icon = null;
     if ($this->headerIcon) {
       if ($this->headerIcon instanceof PHUIIconView) {
         $icon = id(clone $this->headerIcon)
           ->addClass('phui-header-icon');
       } else {
         $icon = id(new PHUIIconView())
           ->setIcon($this->headerIcon)
           ->addClass('phui-header-icon');
       }
     }
 
     $header_content = $this->header;
 
     $href = $this->getHref();
     if ($href !== null) {
       $header_content = phutil_tag(
         'a',
         array(
           'href' => $href,
         ),
         $header_content);
     }
 
     $left[] = phutil_tag(
       'span',
       array(
         'class' => 'phui-header-header',
       ),
       array(
         $space_header,
         $icon,
         $header_content,
       ));
 
     if ($this->subheader) {
       $left[] = phutil_tag(
         'div',
         array(
           'class' => 'phui-header-subheader',
         ),
         array(
           $this->subheader,
         ));
     }
 
     if ($this->properties || $this->policyObject || $this->tags) {
       $property_list = array();
       foreach ($this->properties as $type => $property) {
         switch ($type) {
           case self::PROPERTY_STATUS:
             $property_list[] = $property;
           break;
           default:
             throw new Exception(pht('Incorrect Property Passed'));
           break;
         }
       }
 
       if ($this->policyObject) {
         $property_list[] = $this->renderPolicyProperty($this->policyObject);
       }
 
       if ($this->tags) {
         $property_list[] = $this->tags;
       }
 
       $left[] = phutil_tag(
         'div',
         array(
           'class' => 'phui-header-subheader',
         ),
         $property_list);
     }
 
     // We here at @phabricator
     $header_image = null;
     if ($image) {
     $header_image = phutil_tag(
       'div',
       array(
         'class' => 'phui-header-col1',
       ),
       $image);
     }
 
     // All really love
     $header_left = phutil_tag(
       'div',
       array(
         'class' => 'phui-header-col2',
       ),
       $left);
 
     // Tables and Pokemon.
     $header_right = phutil_tag(
       'div',
       array(
         'class' => 'phui-header-col3',
       ),
       $right);
 
     $header_row = phutil_tag(
       'div',
       array(
         'class' => 'phui-header-row',
       ),
       array(
         $header_image,
         $header_left,
         $header_right,
       ));
 
     return phutil_tag(
       'h1',
       array(
         'class' => 'phui-header-view',
       ),
       $header_row);
   }
 
   private function renderPolicyProperty(PhabricatorPolicyInterface $object) {
     $viewer = $this->getUser();
 
     $policies = PhabricatorPolicyQuery::loadPolicies($viewer, $object);
 
     $view_capability = PhabricatorPolicyCapability::CAN_VIEW;
     $policy = idx($policies, $view_capability);
     if (!$policy) {
       return null;
     }
 
     // If an object is in a Space with a strictly stronger (more restrictive)
     // policy, we show the more restrictive policy. This better aligns the
     // UI hint with the actual behavior.
 
     // NOTE: We'll do this even if the viewer has access to only one space, and
     // show them information about the existence of spaces if they click
     // through.
     $use_space_policy = false;
     if ($object instanceof PhabricatorSpacesInterface) {
       $space_phid = PhabricatorSpacesNamespaceQuery::getObjectSpacePHID(
         $object);
 
       $spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($viewer);
       $space = idx($spaces, $space_phid);
       if ($space) {
         $space_policies = PhabricatorPolicyQuery::loadPolicies(
           $viewer,
           $space);
         $space_policy = idx($space_policies, $view_capability);
         if ($space_policy) {
           if ($space_policy->isStrongerThan($policy)) {
             $policy = $space_policy;
             $use_space_policy = true;
           }
         }
       }
     }
 
     $container_classes = array();
     $container_classes[] = 'policy-header-callout';
     $phid = $object->getPHID();
 
     $policy_name = array($policy->getShortName());
     $policy_icon = $policy->getIcon().' bluegrey';
 
     if ($object instanceof PhabricatorPolicyCodexInterface) {
       $codex = PhabricatorPolicyCodex::newFromObject($object, $viewer);
 
       $codex_name = $codex->getPolicyShortName($policy, $view_capability);
       if ($codex_name !== null) {
         $policy_name = $codex_name;
       }
 
       $codex_icon = $codex->getPolicyIcon($policy, $view_capability);
       if ($codex_icon !== null) {
         $policy_icon = $codex_icon;
       }
 
       $codex_classes = $codex->getPolicyTagClasses($policy, $view_capability);
       foreach ($codex_classes as $codex_class) {
         $container_classes[] = $codex_class;
       }
     }
 
     if (!is_array($policy_name)) {
       $policy_name = (array)$policy_name;
     }
 
     $arrow = id(new PHUIIconView())
       ->setIcon('fa-angle-right')
       ->addClass('policy-tier-separator');
 
     $policy_name = phutil_implode_html($arrow, $policy_name);
 
     $icon = id(new PHUIIconView())
       ->setIcon($policy_icon);
 
     $link = javelin_tag(
       'a',
       array(
         'class' => 'policy-link',
         'href' => '/policy/explain/'.$phid.'/'.$view_capability.'/',
         'sigil' => 'workflow',
       ),
       $policy_name);
 
     return phutil_tag(
       'span',
       array(
         'class' => implode(' ', $container_classes),
       ),
       array($icon, $link));
   }
 
 }
diff --git a/src/view/phui/PHUIPagerView.php b/src/view/phui/PHUIPagerView.php
index 2bb3a8276e..a3821bb4a8 100644
--- a/src/view/phui/PHUIPagerView.php
+++ b/src/view/phui/PHUIPagerView.php
@@ -1,247 +1,247 @@
 <?php
 
 final class PHUIPagerView extends AphrontView {
 
   private $offset;
   private $pageSize = 100;
 
   private $count;
   private $hasMorePages;
 
   private $uri;
   private $pagingParameter;
   private $surroundingPages = 2;
   private $enableKeyboardShortcuts;
 
   public function setPageSize($page_size) {
     $this->pageSize = max(1, $page_size);
     return $this;
   }
 
   public function setOffset($offset) {
     $this->offset = max(0, $offset);
     return $this;
   }
 
   public function getOffset() {
     return $this->offset;
   }
 
   public function getPageSize() {
     return $this->pageSize;
   }
 
   public function setCount($count) {
     $this->count = $count;
     return $this;
   }
 
   public function setHasMorePages($has_more) {
     $this->hasMorePages = $has_more;
     return $this;
   }
 
   public function setURI(PhutilURI $uri, $paging_parameter) {
     $this->uri = $uri;
     $this->pagingParameter = $paging_parameter;
     return $this;
   }
 
   public function readFromRequest(AphrontRequest $request) {
     $this->uri = $request->getRequestURI();
     $this->pagingParameter = 'offset';
     $this->offset = $request->getInt($this->pagingParameter);
     return $this;
   }
 
   public function willShowPagingControls() {
     return $this->hasMorePages || $this->getOffset();
   }
 
   public function getHasMorePages() {
     return $this->hasMorePages;
   }
 
   public function setSurroundingPages($pages) {
     $this->surroundingPages = max(0, $pages);
     return $this;
   }
 
   private function computeCount() {
     if ($this->count !== null) {
       return $this->count;
     }
     return $this->getOffset()
       + $this->getPageSize()
       + ($this->hasMorePages ? 1 : 0);
   }
 
   private function isExactCountKnown() {
     return $this->count !== null;
   }
 
   /**
    * A common paging strategy is to select one extra record and use that to
    * indicate that there's an additional page (this doesn't give you a
    * complete page count but is often faster than counting the total number
    * of items). This method will take a result array, slice it down to the
    * page size if necessary, and call setHasMorePages() if there are more than
    * one page of results.
    *
    *    $results = queryfx_all(
    *      $conn,
    *      'SELECT ... LIMIT %d, %d',
    *      $pager->getOffset(),
    *      $pager->getPageSize() + 1);
    *    $results = $pager->sliceResults($results);
    *
-   * @param   list  Result array.
+   * @param   list  $results Result array.
    * @return  list  One page of results.
    */
   public function sliceResults(array $results) {
     if (count($results) > $this->getPageSize()) {
       $results = array_slice($results, 0, $this->getPageSize(), true);
       $this->setHasMorePages(true);
     }
     return $results;
   }
 
   public function setEnableKeyboardShortcuts($enable) {
     $this->enableKeyboardShortcuts = $enable;
     return $this;
   }
 
   public function render() {
     if (!$this->uri) {
       throw new PhutilInvalidStateException('setURI');
     }
 
     require_celerity_resource('phui-pager-css');
 
     $page = (int)floor($this->getOffset() / $this->getPageSize());
     $last = ((int)ceil($this->computeCount() / $this->getPageSize())) - 1;
     $near = $this->surroundingPages;
 
     $min = $page - $near;
     $max = $page + $near;
 
     // Limit the window size to no larger than the number of available pages.
     if ($max - $min > $last) {
       $max = $min + $last;
       if ($max == $min) {
         return phutil_tag('div', array('class' => 'phui-pager-view'), '');
       }
     }
 
     // Slide the window so it is entirely over displayable pages.
     if ($min < 0) {
       $max += 0 - $min;
       $min += 0 - $min;
     }
 
     if ($max > $last) {
       $min -= $max - $last;
       $max -= $max - $last;
     }
 
 
     // Build up a list of <index, label, css-class> tuples which describe the
     // links we'll display, then render them all at once.
 
     $links = array();
 
     $prev_index = null;
     $next_index = null;
 
     if ($min > 0) {
       $links[] = array(0, pht('First'), null);
     }
 
     if ($page > 0) {
       $links[] = array($page - 1, pht('Prev'), null);
       $prev_index = $page - 1;
     }
 
     for ($ii = $min; $ii <= $max; $ii++) {
       $links[] = array($ii, $ii + 1, ($ii == $page) ? 'current' : null);
     }
 
     if ($page < $last && $last > 0) {
       $links[] = array($page + 1, pht('Next'), null);
       $next_index = $page + 1;
     }
 
     if ($max < ($last - 1)) {
       $links[] = array($last, pht('Last'), null);
     }
 
     $base_uri = $this->uri;
     $parameter = $this->pagingParameter;
 
     if ($this->enableKeyboardShortcuts) {
       $pager_links = array();
       $pager_index = array(
         'prev' => $prev_index,
         'next' => $next_index,
       );
       foreach ($pager_index as $key => $index) {
         if ($index !== null) {
           $display_index = $this->getDisplayIndex($index);
 
           $uri = id(clone $base_uri);
           if ($display_index === null) {
             $uri->removeQueryParam($parameter);
           } else {
             $uri->replaceQueryParam($parameter, $display_index);
           }
 
           $pager_links[$key] = phutil_string_cast($uri);
         }
       }
       Javelin::initBehavior('phabricator-keyboard-pager', $pager_links);
     }
 
     // Convert tuples into rendered nodes.
     $rendered_links = array();
     foreach ($links as $link) {
       list($index, $label, $class) = $link;
       $display_index = $this->getDisplayIndex($index);
 
       $uri = id(clone $base_uri);
       if ($display_index === null) {
         $uri->removeQueryParam($parameter);
       } else {
         $uri->replaceQueryParam($parameter, $display_index);
       }
 
       $rendered_links[] = id(new PHUIButtonView())
         ->setTag('a')
         ->setHref($uri)
         ->setColor(PHUIButtonView::GREY)
         ->addClass('mml')
         ->addClass($class)
         ->setText($label);
     }
 
     return phutil_tag(
       'div',
       array(
         'class' => 'phui-pager-view',
       ),
       $rendered_links);
   }
 
   private function getDisplayIndex($page_index) {
     $page_size = $this->getPageSize();
     // Use a 1-based sequence for display so that the number in the URI is
     // the same as the page number you're on.
     if ($page_index == 0) {
       // No need for the first page to say page=1.
       $display_index = null;
     } else {
       $display_index = $page_index * $page_size;
     }
     return $display_index;
   }
 
 }
diff --git a/src/view/viewutils.php b/src/view/viewutils.php
index 956f6ca2c2..47a6f0178d 100644
--- a/src/view/viewutils.php
+++ b/src/view/viewutils.php
@@ -1,145 +1,145 @@
 <?php
 
 function phabricator_date($epoch, PhabricatorUser $user) {
   return phabricator_format_local_time(
     $epoch,
     $user,
     phutil_date_format($epoch));
 }
 
 function phabricator_relative_date($epoch, $user, $on = false) {
   static $today;
   static $yesterday;
 
   if (!$today || !$yesterday) {
     $now = time();
     $today = phabricator_date($now, $user);
     $yesterday = phabricator_date($now - 86400, $user);
   }
 
   $date = phabricator_date($epoch, $user);
 
   if ($date === $today) {
     return 'today';
   }
 
   if ($date === $yesterday) {
     return 'yesterday';
   }
 
   return (($on ? 'on ' : '').$date);
 }
 
 function phabricator_time($epoch, $user) {
   $time_key = PhabricatorTimeFormatSetting::SETTINGKEY;
   return phabricator_format_local_time(
     $epoch,
     $user,
     $user->getUserSetting($time_key));
 }
 
 function phabricator_dual_datetime($epoch, $user) {
   $screen_view = phabricator_datetime($epoch, $user);
   $print_view = phabricator_absolute_datetime($epoch, $user);
 
   $screen_tag = javelin_tag(
     'span',
     array(
       'print' => false,
     ),
     $screen_view);
 
   $print_tag = javelin_tag(
     'span',
     array(
       'print' => true,
     ),
     $print_view);
 
   return array(
     $screen_tag,
     $print_tag,
   );
 }
 
 function phabricator_absolute_datetime($epoch, $user) {
   $format = 'Y-m-d H:i:s (\\U\\T\\CP)';
 
   $datetime = phabricator_format_local_time($epoch, $user, $format);
   $datetime = preg_replace('/(UTC[+-])0?([^:]+)(:00)?/', '\\1\\2', $datetime);
 
   return $datetime;
 }
 
 function phabricator_datetime($epoch, $user) {
   $time_key = PhabricatorTimeFormatSetting::SETTINGKEY;
   return phabricator_format_local_time(
     $epoch,
     $user,
     pht('%s, %s',
       phutil_date_format($epoch),
       $user->getUserSetting($time_key)));
 }
 
 function phabricator_datetimezone($epoch, $user) {
   $datetime = phabricator_datetime($epoch, $user);
   $timezone = phabricator_format_local_time($epoch, $user, 'T');
 
   // Some obscure timezones just render as "+03" or "-09". Make these render
   // as "UTC+3" instead.
   if (preg_match('/^[+-]/', $timezone)) {
     $timezone = (int)trim($timezone, '+');
     if ($timezone < 0) {
       $timezone = pht('UTC-%s', $timezone);
     } else {
       $timezone = pht('UTC+%s', $timezone);
     }
   }
 
   return pht('%s (%s)', $datetime, $timezone);
 }
 
 /**
  * This function does not usually need to be called directly. Instead, call
  * @{function:phabricator_date}, @{function:phabricator_time}, or
  * @{function:phabricator_datetime}.
  *
- * @param int Unix epoch timestamp.
- * @param PhabricatorUser User viewing the timestamp.
- * @param string Date format, as per DateTime class.
+ * @param int $epoch Unix epoch timestamp.
+ * @param PhabricatorUser $user User viewing the timestamp.
+ * @param string $format Date format, as per DateTime class.
  * @return string Formatted, local date/time.
  */
 function phabricator_format_local_time($epoch, $user, $format) {
   if (!$epoch) {
     // If we're missing date information for something, the DateTime class will
     // throw an exception when we try to construct an object. Since this is a
     // display function, just return an empty string.
     return '';
   }
 
   $user_zone = $user->getTimezoneIdentifier();
 
   static $zones = array();
   if (empty($zones[$user_zone])) {
     $zones[$user_zone] = new DateTimeZone($user_zone);
   }
   $zone = $zones[$user_zone];
 
   // NOTE: Although DateTime takes a second DateTimeZone parameter to its
   // constructor, it ignores it if the date string includes timezone
   // information. Further, it treats epoch timestamps ("@946684800") as having
   // a UTC timezone. Set the timezone explicitly after constructing the object.
   try {
     $date = new DateTime('@'.$epoch);
   } catch (Exception $ex) {
     // NOTE: DateTime throws an empty exception if the format is invalid,
     // just replace it with a useful one.
     throw new Exception(
       pht("Construction of a DateTime() with epoch '%s' ".
       "raised an exception.", $epoch));
   }
 
   $date->setTimezone($zone);
 
   return PhutilTranslator::getInstance()->translateDate($format, $date);
 }
diff --git a/support/startup/PhabricatorClientLimit.php b/support/startup/PhabricatorClientLimit.php
index c43e4b42c0..ea2f39d217 100644
--- a/support/startup/PhabricatorClientLimit.php
+++ b/support/startup/PhabricatorClientLimit.php
@@ -1,291 +1,290 @@
 <?php
 
 abstract class PhabricatorClientLimit {
 
   private $limitKey;
   private $clientKey;
   private $limit;
 
   final public function setLimitKey($limit_key) {
     $this->limitKey = $limit_key;
     return $this;
   }
 
   final public function getLimitKey() {
     return $this->limitKey;
   }
 
   final public function setClientKey($client_key) {
     $this->clientKey = $client_key;
     return $this;
   }
 
   final public function getClientKey() {
     return $this->clientKey;
   }
 
   final public function setLimit($limit) {
     $this->limit = $limit;
     return $this;
   }
 
   final public function getLimit() {
     return $this->limit;
   }
 
   final public function didConnect() {
     // NOTE: We can not use pht() here because this runs before libraries
     // load.
 
     if (!function_exists('apc_fetch') && !function_exists('apcu_fetch')) {
       throw new Exception(
         'You can not configure connection rate limits unless APC/APCu are '.
         'available. Rate limits rely on APC/APCu to track clients and '.
         'connections.');
     }
 
     if ($this->getClientKey() === null) {
       throw new Exception(
         'You must configure a client key when defining a rate limit.');
     }
 
     if ($this->getLimitKey() === null) {
       throw new Exception(
         'You must configure a limit key when defining a rate limit.');
     }
 
     if ($this->getLimit() === null) {
       throw new Exception(
         'You must configure a limit when defining a rate limit.');
     }
 
     $points = $this->getConnectScore();
     if ($points) {
       $this->addScore($points);
     }
 
     $score = $this->getScore();
     if (!$this->shouldRejectConnection($score)) {
       // Client has not hit the limit, so continue processing the request.
       return null;
     }
 
     $penalty = $this->getPenaltyScore();
     if ($penalty) {
       $this->addScore($penalty);
       $score += $penalty;
     }
 
     return $this->getRateLimitReason($score);
   }
 
   final public function didDisconnect(array $request_state) {
     $score = $this->getDisconnectScore($request_state);
     if ($score) {
       $this->addScore($score);
     }
   }
 
 
   /**
    * Get the number of seconds for each rate bucket.
    *
    * For example, a value of 60 will create one-minute buckets.
    *
    * @return int Number of seconds per bucket.
    */
   abstract protected function getBucketDuration();
 
 
   /**
    * Get the total number of rate limit buckets to retain.
    *
    * @return int Total number of rate limit buckets to retain.
    */
   abstract protected function getBucketCount();
 
 
   /**
    * Get the score to add when a client connects.
    *
    * @return double Connection score.
    */
   abstract protected function getConnectScore();
 
 
   /**
    * Get the number of penalty points to add when a client hits a rate limit.
    *
    * @return double Penalty score.
    */
   abstract protected function getPenaltyScore();
 
 
   /**
    * Get the score to add when a client disconnects.
    *
    * @return double Connection score.
    */
   abstract protected function getDisconnectScore(array $request_state);
 
 
   /**
    * Get a human-readable explanation of why the client is being rejected.
    *
    * @return string Brief rejection message.
    */
   abstract protected function getRateLimitReason($score);
 
 
   /**
    * Determine whether to reject a connection.
    *
    * @return bool True to reject the connection.
    */
   abstract protected function shouldRejectConnection($score);
 
 
   /**
    * Get the APC key for the smallest stored bucket.
    *
    * @return string APC key for the smallest stored bucket.
    * @task ratelimit
    */
   private function getMinimumBucketCacheKey() {
     $limit_key = $this->getLimitKey();
     return "limit:min:{$limit_key}";
   }
 
 
   /**
    * Get the current bucket ID for storing rate limit scores.
    *
    * @return int The current bucket ID.
    */
   private function getCurrentBucketID() {
     return (int)(time() / $this->getBucketDuration());
   }
 
 
   /**
    * Get the APC key for a given bucket.
    *
-   * @param int Bucket to get the key for.
+   * @param int $bucket_id Bucket to get the key for.
    * @return string APC key for the bucket.
    */
   private function getBucketCacheKey($bucket_id) {
     $limit_key = $this->getLimitKey();
     return "limit:bucket:{$limit_key}:{$bucket_id}";
   }
 
 
   /**
    * Add points to the rate limit score for some client.
    *
-   * @param string  Some key which identifies the client making the request.
-   * @param float   The cost for this request; more points pushes them toward
-   *                the limit faster.
+   * @param float   $score The cost for this request; more points pushes them
+   *                toward the limit faster.
    * @return this
    */
   private function addScore($score) {
     $is_apcu = (bool)function_exists('apcu_fetch');
 
     $current = $this->getCurrentBucketID();
     $bucket_key = $this->getBucketCacheKey($current);
 
     // There's a bit of a race here, if a second process reads the bucket
     // before this one writes it, but it's fine if we occasionally fail to
     // record a client's score. If they're making requests fast enough to hit
     // rate limiting, we'll get them soon enough.
 
     if ($is_apcu) {
       $bucket = apcu_fetch($bucket_key);
     } else {
       $bucket = apc_fetch($bucket_key);
     }
 
     if (!is_array($bucket)) {
       $bucket = array();
     }
 
     $client_key = $this->getClientKey();
     if (empty($bucket[$client_key])) {
       $bucket[$client_key] = 0;
     }
 
     $bucket[$client_key] += $score;
 
     if ($is_apcu) {
       @apcu_store($bucket_key, $bucket);
     } else {
       @apc_store($bucket_key, $bucket);
     }
 
     return $this;
   }
 
 
   /**
    * Get the current rate limit score for a given client.
    *
    * @return float The client's current score.
    * @task ratelimit
    */
   private function getScore() {
     $is_apcu = (bool)function_exists('apcu_fetch');
 
     // Identify the oldest bucket stored in APC.
     $min_key = $this->getMinimumBucketCacheKey();
     if ($is_apcu) {
       $min = apcu_fetch($min_key);
     } else {
       $min = apc_fetch($min_key);
     }
 
     // If we don't have any buckets stored yet, store the current bucket as
     // the oldest bucket.
     $cur = $this->getCurrentBucketID();
     if (!$min) {
       if ($is_apcu) {
         @apcu_store($min_key, $cur);
       } else {
         @apc_store($min_key, $cur);
       }
       $min = $cur;
     }
 
     // Destroy any buckets that are older than the minimum bucket we're keeping
     // track of. Under load this normally shouldn't do anything, but will clean
     // up an old bucket once per minute.
     $count = $this->getBucketCount();
     for ($cursor = $min; $cursor < ($cur - $count); $cursor++) {
       $bucket_key = $this->getBucketCacheKey($cursor);
       if ($is_apcu) {
         apcu_delete($bucket_key);
         @apcu_store($min_key, $cursor + 1);
       } else {
         apc_delete($bucket_key);
         @apc_store($min_key, $cursor + 1);
       }
     }
 
     $client_key = $this->getClientKey();
 
     // Now, sum up the client's scores in all of the active buckets.
     $score = 0;
     for (; $cursor <= $cur; $cursor++) {
       $bucket_key = $this->getBucketCacheKey($cursor);
       if ($is_apcu) {
         $bucket = apcu_fetch($bucket_key);
       } else {
         $bucket = apc_fetch($bucket_key);
       }
       if (isset($bucket[$client_key])) {
         $score += $bucket[$client_key];
       }
     }
 
     return $score;
   }
 
 }
diff --git a/support/startup/PhabricatorStartup.php b/support/startup/PhabricatorStartup.php
index 2ae5993dfb..38c8af13b1 100644
--- a/support/startup/PhabricatorStartup.php
+++ b/support/startup/PhabricatorStartup.php
@@ -1,791 +1,793 @@
 <?php
 
 /**
  * Handle request startup, before loading the environment or libraries. This
  * class bootstraps the request state up to the point where we can enter
  * Phabricator code.
  *
  * NOTE: This class MUST NOT have any dependencies. It runs before libraries
  * load.
  *
  * Rate Limiting
  * =============
  *
  * Phabricator limits the rate at which clients can request pages, and issues
  * HTTP 429 "Too Many Requests" responses if clients request too many pages too
  * quickly. Although this is not a complete defense against high-volume attacks,
  * it can  protect an install against aggressive crawlers, security scanners,
  * and some types of malicious activity.
  *
  * To perform rate limiting, each page increments a score counter for the
  * requesting user's IP. The page can give the IP more points for an expensive
  * request, or fewer for an authetnicated request.
  *
  * Score counters are kept in buckets, and writes move to a new bucket every
  * minute. After a few minutes (defined by @{method:getRateLimitBucketCount}),
  * the oldest bucket is discarded. This provides a simple mechanism for keeping
  * track of scores without needing to store, access, or read very much data.
  *
  * Users are allowed to accumulate up to 1000 points per minute, averaged across
  * all of the tracked buckets.
  *
  * @task info         Accessing Request Information
  * @task hook         Startup Hooks
  * @task apocalypse   In Case Of Apocalypse
  * @task validation   Validation
  * @task ratelimit    Rate Limiting
  * @task phases       Startup Phase Timers
  * @task request-path Request Path
  */
 final class PhabricatorStartup {
 
   private static $startTime;
   private static $debugTimeLimit;
   private static $accessLog;
   private static $capturingOutput;
   private static $rawInput;
   private static $oldMemoryLimit;
   private static $phases;
 
   private static $limits = array();
   private static $requestPath;
 
 
 /* -(  Accessing Request Information  )-------------------------------------- */
 
 
   /**
    * @task info
    */
   public static function getStartTime() {
     return self::$startTime;
   }
 
 
   /**
    * @task info
    */
   public static function getMicrosecondsSinceStart() {
     // This is the same as "phutil_microseconds_since()", but we may not have
     // loaded libraries yet.
     return (int)(1000000 * (microtime(true) - self::getStartTime()));
   }
 
 
   /**
    * @task info
    */
   public static function setAccessLog($access_log) {
     self::$accessLog = $access_log;
   }
 
 
   /**
    * @task info
    */
   public static function getRawInput() {
     if (self::$rawInput === null) {
       $stream = new AphrontRequestStream();
 
       if (isset($_SERVER['HTTP_CONTENT_ENCODING'])) {
         $encoding = trim($_SERVER['HTTP_CONTENT_ENCODING']);
         $stream->setEncoding($encoding);
       }
 
       $input = '';
       do {
         $bytes = $stream->readData();
         if ($bytes === null) {
           break;
         }
         $input .= $bytes;
       } while (true);
 
       self::$rawInput = $input;
     }
 
     return self::$rawInput;
   }
 
 
 /* -(  Startup Hooks  )------------------------------------------------------ */
 
 
   /**
-   * @param float Request start time, from `microtime(true)`.
+   * @param float $start_time Request start time, from `microtime(true)`.
    * @task hook
    */
   public static function didStartup($start_time) {
     self::$startTime = $start_time;
 
     self::$phases = array();
 
     self::$accessLog = null;
     self::$requestPath = null;
 
     static $registered;
     if (!$registered) {
       // NOTE: This protects us against multiple calls to didStartup() in the
       // same request, but also against repeated requests to the same
       // interpreter state, which we may implement in the future.
       register_shutdown_function(array(__CLASS__, 'didShutdown'));
       $registered = true;
     }
 
     self::setupPHP();
     self::verifyPHP();
 
     // If we've made it this far, the environment isn't completely broken so
     // we can switch over to relying on our own exception recovery mechanisms.
     ini_set('display_errors', 0);
 
     self::connectRateLimits();
 
     self::normalizeInput();
 
     self::readRequestPath();
 
     self::beginOutputCapture();
   }
 
 
   /**
    * @task hook
    */
   public static function didShutdown() {
     // Disconnect any active rate limits before we shut down. If we don't do
     // this, requests which exit early will lock a slot in any active
     // connection limits, and won't count for rate limits.
     self::disconnectRateLimits(array());
 
     $event = error_get_last();
 
     if (!$event) {
       return;
     }
 
     switch ($event['type']) {
       case E_ERROR:
       case E_PARSE:
       case E_COMPILE_ERROR:
         break;
       default:
         return;
     }
 
     $msg = ">>> UNRECOVERABLE FATAL ERROR <<<\n\n";
     if ($event) {
       // Even though we should be emitting this as text-plain, escape things
       // just to be sure since we can't really be sure what the program state
       // is when we get here.
       $msg .= htmlspecialchars(
         $event['message']."\n\n".$event['file'].':'.$event['line'],
         ENT_QUOTES,
         'UTF-8');
     }
 
     // flip dem tables
     $msg .= "\n\n\n";
     $msg .= "\xe2\x94\xbb\xe2\x94\x81\xe2\x94\xbb\x20\xef\xb8\xb5\x20\xc2\xaf".
             "\x5c\x5f\x28\xe3\x83\x84\x29\x5f\x2f\xc2\xaf\x20\xef\xb8\xb5\x20".
             "\xe2\x94\xbb\xe2\x94\x81\xe2\x94\xbb";
 
     self::didFatal($msg);
   }
 
   public static function loadCoreLibraries() {
     $phabricator_root = dirname(dirname(dirname(__FILE__)));
     $libraries_root = dirname($phabricator_root);
 
     $root = null;
     if (!empty($_SERVER['PHUTIL_LIBRARY_ROOT'])) {
       $root = $_SERVER['PHUTIL_LIBRARY_ROOT'];
     }
 
     ini_set(
       'include_path',
       $libraries_root.PATH_SEPARATOR.ini_get('include_path'));
 
     $ok = @include_once $root.'arcanist/src/init/init-library.php';
     if (!$ok) {
       self::didFatal(
         'Unable to load the "Arcanist" library. Put "arcanist/" next to '.
         '"phorge/" on disk.');
     }
 
     // Load Phabricator itself using the absolute path, so we never end up doing
     // anything surprising (loading index.php and libraries from different
     // directories).
     phutil_load_library($phabricator_root.'/src');
   }
 
 /* -(  Output Capture  )----------------------------------------------------- */
 
 
   public static function beginOutputCapture() {
     if (self::$capturingOutput) {
       self::didFatal('Already capturing output!');
     }
     self::$capturingOutput = true;
     ob_start();
   }
 
 
   public static function endOutputCapture() {
     if (!self::$capturingOutput) {
       return null;
     }
     self::$capturingOutput = false;
     return ob_get_clean();
   }
 
 
 /* -(  Debug Time Limit  )--------------------------------------------------- */
 
 
   /**
    * Set a time limit (in seconds) for the current script. After time expires,
    * the script fatals.
    *
    * This works like `max_execution_time`, but prints out a useful stack trace
    * when the time limit expires. This is primarily intended to make it easier
    * to debug pages which hang by allowing extraction of a stack trace: set a
    * short debug limit, then use the trace to figure out what's happening.
    *
    * The limit is implemented with a tick function, so enabling it implies
    * some accounting overhead.
    *
-   * @param int Time limit in seconds.
+   * @param int $limit Time limit in seconds.
    * @return void
    */
   public static function setDebugTimeLimit($limit) {
     self::$debugTimeLimit = $limit;
 
     static $initialized = false;
     if (!$initialized) {
       declare(ticks=1);
       register_tick_function(array(__CLASS__, 'onDebugTick'));
       $initialized = true;
     }
   }
 
 
   /**
    * Callback tick function used by @{method:setDebugTimeLimit}.
    *
    * Fatals with a useful stack trace after the time limit expires.
    *
    * @return void
    */
   public static function onDebugTick() {
     $limit = self::$debugTimeLimit;
     if (!$limit) {
       return;
     }
 
     $elapsed = (microtime(true) - self::getStartTime());
     if ($elapsed > $limit) {
       $frames = array();
       foreach (debug_backtrace() as $frame) {
         $file = isset($frame['file']) ? $frame['file'] : '-';
         $file = basename($file);
 
         $line = isset($frame['line']) ? $frame['line'] : '-';
         $class = isset($frame['class']) ? $frame['class'].'->' : null;
         $func = isset($frame['function']) ? $frame['function'].'()' : '?';
 
         $frames[] = "{$file}:{$line} {$class}{$func}";
       }
 
       self::didFatal(
         "Request aborted by debug time limit after {$limit} seconds.\n\n".
         "STACK TRACE\n".
         implode("\n", $frames));
     }
   }
 
 
 /* -(  In Case of Apocalypse  )---------------------------------------------- */
 
 
   /**
    * Fatal the request completely in response to an exception, sending a plain
    * text message to the client. Calls @{method:didFatal} internally.
    *
-   * @param   string    Brief description of the exception context, like
+   * @param   string    $note Brief description of the exception context, like
    *                    `"Rendering Exception"`.
-   * @param   Throwable The exception itself.
-   * @param   bool      True if it's okay to show the exception's stack trace
-   *                    to the user. The trace will always be logged.
+   * @param   Throwable $ex The exception itself.
+   * @param   bool      $show_trace True if it's okay to show the exception's
+   *                    stack trace to the user. The trace will always be
+   *                    logged.
    *
    * @task apocalypse
    */
   public static function didEncounterFatalException(
     $note,
     $ex,
     $show_trace) {
 
     $message = '['.$note.'/'.get_class($ex).'] '.$ex->getMessage();
 
     $full_message = $message;
     $full_message .= "\n\n";
     $full_message .= $ex->getTraceAsString();
 
     if ($show_trace) {
       $message = $full_message;
     }
 
     self::didFatal($message, $full_message);
   }
 
 
   /**
    * Fatal the request completely, sending a plain text message to the client.
    *
-   * @param   string  Plain text message to send to the client.
-   * @param   string  Plain text message to send to the error log. If not
-   *                  provided, the client message is used. You can pass a more
-   *                  detailed message here (e.g., with stack traces) to avoid
-   *                  showing it to users.
+   * @param   string  $message Plain text message to send to the client.
+   * @param   string? $log_message Plain text message to send to the error log.
+   *                  If not provided, the client message is used. You can pass
+   *                  a more detailed message here (e.g., with stack traces) to
+   *                  avoid showing it to users.
    * @return  exit    This method **does not return**.
    *
    * @task apocalypse
    */
   public static function didFatal($message, $log_message = null) {
     if ($log_message === null) {
       $log_message = $message;
     }
 
     self::endOutputCapture();
     $access_log = self::$accessLog;
     if ($access_log) {
       // We may end up here before the access log is initialized, e.g. from
       // verifyPHP().
       $access_log->setData(
         array(
           'c' => 500,
         ));
       $access_log->write();
     }
 
     header(
       'Content-Type: text/plain; charset=utf-8',
       $replace = true,
       $http_error = 500);
 
     error_log($log_message);
     echo $message."\n";
 
     exit(1);
   }
 
 
 /* -(  Validation  )--------------------------------------------------------- */
 
 
   /**
    * @task validation
    */
   private static function setupPHP() {
     error_reporting(E_ALL | E_STRICT);
     self::$oldMemoryLimit = ini_get('memory_limit');
     ini_set('memory_limit', -1);
 
     // If we have libxml, disable the incredibly dangerous entity loader.
     // PHP 8 deprecates this function and disables this by default; remove once
     // PHP 7 is no longer supported or a future version has removed the function
     // entirely.
     if (function_exists('libxml_disable_entity_loader')) {
       @libxml_disable_entity_loader(true);
     }
 
     // See T13060. If the locale for this process (the parent process) is not
     // a UTF-8 locale we can encounter problems when launching subprocesses
     // which receive UTF-8 parameters in their command line argument list.
     @setlocale(LC_ALL, 'en_US.UTF-8');
 
     $config_map = array(
       // See PHI1894. Keep "args" in exception backtraces.
       'zend.exception_ignore_args' => 0,
 
       // See T13100. We'd like the regex engine to fail, rather than segfault,
       // if handed a pathological regular expression.
       'pcre.backtrack_limit' => 10000,
       'pcre.recusion_limit' => 10000,
 
       // NOTE: Arcanist applies a similar set of startup options for CLI
       // environments in "init-script.php". Changes here may also be
       // appropriate to apply there.
     );
 
     foreach ($config_map as $config_key => $config_value) {
       ini_set($config_key, $config_value);
     }
   }
 
 
   /**
    * @task validation
    */
   public static function getOldMemoryLimit() {
     return self::$oldMemoryLimit;
   }
 
   /**
    * @task validation
    */
   private static function normalizeInput() {
     // Replace superglobals with unfiltered versions, disrespect php.ini (we
     // filter ourselves).
 
     // NOTE: We don't filter INPUT_SERVER because we don't want to overwrite
     // changes made in "preamble.php".
 
     // NOTE: WE don't filter INPUT_POST because we may be constructing it
     // lazily if "enable_post_data_reading" is disabled.
 
     $filter = array(
       INPUT_GET,
       INPUT_ENV,
       INPUT_COOKIE,
     );
     foreach ($filter as $type) {
       $filtered = filter_input_array($type, FILTER_UNSAFE_RAW);
       if (!is_array($filtered)) {
         continue;
       }
       switch ($type) {
         case INPUT_GET:
           $_GET = array_merge($_GET, $filtered);
           break;
         case INPUT_COOKIE:
           $_COOKIE = array_merge($_COOKIE, $filtered);
           break;
         case INPUT_ENV;
           $env = array_merge($_ENV, $filtered);
           $_ENV = self::filterEnvSuperglobal($env);
           break;
       }
     }
 
     self::rebuildRequest();
   }
 
   /**
    * @task validation
    */
   public static function rebuildRequest() {
     // Rebuild $_REQUEST, respecting order declared in ".ini" files.
     $order = ini_get('request_order');
 
     if (!$order) {
       $order = ini_get('variables_order');
     }
 
     if (!$order) {
       // $_REQUEST will be empty, so leave it alone.
       return;
     }
 
     $_REQUEST = array();
     for ($ii = 0; $ii < strlen($order); $ii++) {
       switch ($order[$ii]) {
         case 'G':
           $_REQUEST = array_merge($_REQUEST, $_GET);
           break;
         case 'P':
           $_REQUEST = array_merge($_REQUEST, $_POST);
           break;
         case 'C':
           $_REQUEST = array_merge($_REQUEST, $_COOKIE);
           break;
         default:
           // $_ENV and $_SERVER never go into $_REQUEST.
           break;
       }
     }
   }
 
 
   /**
    * Adjust `$_ENV` before execution.
    *
    * Adjustments here primarily impact the environment as seen by subprocesses.
    * The environment is forwarded explicitly by @{class:ExecFuture}.
    *
-   * @param map<string, wild> Input `$_ENV`.
+   * @param map<string, wild> $env Input `$_ENV`.
    * @return map<string, string> Suitable `$_ENV`.
    * @task validation
    */
   private static function filterEnvSuperglobal(array $env) {
 
     // In some configurations, we may get "argc" and "argv" set in $_ENV.
     // These are not real environmental variables, and "argv" may have an array
     // value which can not be forwarded to subprocesses. Remove these from the
     // environment if they are present.
     unset($env['argc']);
     unset($env['argv']);
 
     return $env;
   }
 
 
   /**
    * @task validation
    */
   private static function verifyPHP() {
     $required_version = '5.2.3';
     if (version_compare(PHP_VERSION, $required_version) < 0) {
       self::didFatal(
         "You are running PHP version '".PHP_VERSION."', which is older than ".
         "the minimum version, '{$required_version}'. Update to at least ".
         "'{$required_version}'.");
     }
 
     if (function_exists('get_magic_quotes_gpc')) {
       if (@get_magic_quotes_gpc()) {
         self::didFatal(
           'Your server is configured with the PHP language feature '.
           '"magic_quotes_gpc" enabled.'.
           "\n\n".
           'This feature is "highly discouraged" by PHP\'s developers, and '.
           'has been removed entirely in PHP8.'.
           "\n\n".
           'You must disable "magic_quotes_gpc" to run Phabricator. Consult '.
           'the PHP manual for instructions.');
       }
     }
 
     if (extension_loaded('apc')) {
       $apc_version = phpversion('apc');
       $known_bad = array(
         '3.1.14' => true,
         '3.1.15' => true,
         '3.1.15-dev' => true,
       );
       if (isset($known_bad[$apc_version])) {
         self::didFatal(
           "You have APC {$apc_version} installed. This version of APC is ".
           "known to be bad, and does not work with Phabricator (it will ".
           "cause Phabricator to fatal unrecoverably with nonsense errors). ".
           "Downgrade to version 3.1.13.");
       }
     }
 
     if (isset($_SERVER['HTTP_PROXY'])) {
       self::didFatal(
         'This HTTP request included a "Proxy:" header, poisoning the '.
         'environment (CVE-2016-5385 / httpoxy). Declining to process this '.
         'request. For details, see: https://secure.phabricator.com/T11359');
     }
   }
 
 
   /**
    * @task request-path
    */
   private static function readRequestPath() {
 
     // See T13575. The request path may be provided in:
     //
     //  - the "$_GET" parameter "__path__" (normal for Apache and nginx); or
     //  - the "$_SERVER" parameter "REQUEST_URI" (normal for the PHP builtin
     //    webserver).
     //
     // Locate it wherever it is, and store it for later use. Note that writing
     // to "$_REQUEST" here won't always work, because later code may rebuild
     // "$_REQUEST" from other sources.
 
     if (isset($_REQUEST['__path__']) && strlen($_REQUEST['__path__'])) {
       self::setRequestPath($_REQUEST['__path__']);
       return;
     }
 
     // Compatibility with PHP 5.4+ built-in web server.
     if (php_sapi_name() == 'cli-server') {
       $path = parse_url($_SERVER['REQUEST_URI']);
       self::setRequestPath($path['path']);
       return;
     }
 
     if (!isset($_REQUEST['__path__'])) {
       self::didFatal(
         "Request parameter '__path__' is not set. Your rewrite rules ".
         "are not configured correctly.");
     }
 
     if (!strlen($_REQUEST['__path__'])) {
       self::didFatal(
         "Request parameter '__path__' is set, but empty. Your rewrite rules ".
         "are not configured correctly. The '__path__' should always ".
         "begin with a '/'.");
     }
   }
 
   /**
    * @task request-path
    */
   public static function getRequestPath() {
     $path = self::$requestPath;
 
     if ($path === null) {
       self::didFatal(
         'Request attempted to access request path, but no request path is '.
         'available for this request. You may be calling web request code '.
         'from a non-request context, or your webserver may not be passing '.
         'a request path to Phabricator in a format that it understands.');
     }
 
     return $path;
   }
 
   /**
    * @task request-path
    */
   public static function setRequestPath($path) {
     self::$requestPath = $path;
   }
 
 
 /* -(  Rate Limiting  )------------------------------------------------------ */
 
 
   /**
    * Add a new client limits.
    *
-   * @param PhabricatorClientLimit New limit.
+   * @param PhabricatorClientLimit $limit New limit.
    * @return PhabricatorClientLimit The limit.
    */
   public static function addRateLimit(PhabricatorClientLimit $limit) {
     self::$limits[] = $limit;
     return $limit;
   }
 
 
   /**
    * Apply configured rate limits.
    *
    * If any limit is exceeded, this method terminates the request.
    *
    * @return void
    * @task ratelimit
    */
   private static function connectRateLimits() {
     $limits = self::$limits;
 
     $reason = null;
     $connected = array();
     foreach ($limits as $limit) {
       $reason = $limit->didConnect();
       $connected[] = $limit;
       if ($reason !== null) {
         break;
       }
     }
 
     // If we're killing the request here, disconnect any limits that we
     // connected to try to keep the accounting straight.
     if ($reason !== null) {
       foreach ($connected as $limit) {
         $limit->didDisconnect(array());
       }
 
       self::didRateLimit($reason);
     }
   }
 
 
   /**
    * Tear down rate limiting and allow limits to score the request.
    *
-   * @param map<string, wild> Additional, freeform request state.
+   * @param map<string, wild> $request_state Additional, freeform request
+   *   state.
    * @return void
    * @task ratelimit
    */
   public static function disconnectRateLimits(array $request_state) {
     $limits = self::$limits;
 
     // Remove all limits before disconnecting them so this works properly if
     // it runs twice. (We run this automatically as a shutdown handler.)
     self::$limits = array();
 
     foreach ($limits as $limit) {
       $limit->didDisconnect($request_state);
     }
   }
 
 
 
   /**
    * Emit an HTTP 429 "Too Many Requests" response (indicating that the user
    * has exceeded application rate limits) and exit.
    *
    * @return exit This method **does not return**.
    * @task ratelimit
    */
   private static function didRateLimit($reason) {
     header(
       'Content-Type: text/plain; charset=utf-8',
       $replace = true,
       $http_error = 429);
 
     echo $reason;
 
     exit(1);
   }
 
 
 /* -(  Startup Timers  )----------------------------------------------------- */
 
 
   /**
    * Record the beginning of a new startup phase.
    *
    * For phases which occur before @{class:PhabricatorStartup} loads, save the
    * time and record it with @{method:recordStartupPhase} after the class is
    * available.
    *
-   * @param string Phase name.
+   * @param string $phase Phase name.
    * @task phases
    */
   public static function beginStartupPhase($phase) {
     self::recordStartupPhase($phase, microtime(true));
   }
 
 
   /**
    * Record the start time of a previously executed startup phase.
    *
    * For startup phases which occur after @{class:PhabricatorStartup} loads,
    * use @{method:beginStartupPhase} instead. This method can be used to
    * record a time before the class loads, then hand it over once the class
    * becomes available.
    *
-   * @param string Phase name.
-   * @param float Phase start time, from `microtime(true)`.
+   * @param string $phase Phase name.
+   * @param float $time Phase start time, from `microtime(true)`.
    * @task phases
    */
   public static function recordStartupPhase($phase, $time) {
     self::$phases[$phase] = $time;
   }
 
 
   /**
    * Get information about startup phase timings.
    *
    * Sometimes, performance problems can occur before we start the profiler.
    * Since the profiler can't examine these phases, it isn't useful in
    * understanding their performance costs.
    *
    * Instead, the startup process marks when it enters various phases using
    * @{method:beginStartupPhase}. A later call to this method can retrieve this
    * information, which can be examined to gain greater insight into where
    * time was spent. The output is still crude, but better than nothing.
    *
    * @task phases
    */
   public static function getPhases() {
     return self::$phases;
   }
 
 }
diff --git a/support/startup/preamble-utils.php b/support/startup/preamble-utils.php
index 8dd3b502d6..c04f06b900 100644
--- a/support/startup/preamble-utils.php
+++ b/support/startup/preamble-utils.php
@@ -1,77 +1,77 @@
 <?php
 
 /**
  * Parse the "X_FORWARDED_FOR" HTTP header to determine the original client
  * address.
  *
- * @param  int  Number of devices to trust.
+ * @param  int? $layers Number of devices to trust.
  * @return void
  */
 function preamble_trust_x_forwarded_for_header($layers = 1) {
   if (!is_int($layers) || ($layers < 1)) {
     echo
       'preamble_trust_x_forwarded_for_header(<layers>): '.
       '"layers" parameter must an integer larger than 0.'."\n";
     echo "\n";
     exit(1);
   }
 
   if (!isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
     return;
   }
 
   $forwarded_for = $_SERVER['HTTP_X_FORWARDED_FOR'];
   if (!strlen($forwarded_for)) {
     return;
   }
 
   $address = preamble_get_x_forwarded_for_address($forwarded_for, $layers);
 
   $_SERVER['REMOTE_ADDR'] = $address;
 }
 
 function preamble_get_x_forwarded_for_address($raw_header, $layers) {
   // The raw header may be a list of IPs, like "1.2.3.4, 4.5.6.7", if the
   // request the load balancer received also had this header. In particular,
   // this happens routinely with requests received through a CDN, but can also
   // happen illegitimately if the client just makes up an "X-Forwarded-For"
   // header full of lies.
 
   // We can only trust the N elements at the end of the list which correspond
   // to network-adjacent devices we control. Usually, we're behind a single
   // load balancer and "N" is 1, so we want to take the last element in the
   // list.
 
   // In some cases, "N" may be more than 1, if the network is configured so
   // that that requests are routed through multiple layers of load balancers
   // and proxies. In this case, we want to take the Nth-to-last element of
   // the list.
 
   $addresses = explode(',', $raw_header);
 
   // If we have more than one trustworthy device on the network path, discard
   // corresponding elements from the list. For example, if we have 7 devices,
   // we want to discard the last 6 elements of the list.
 
   // The final device address does not appear in the list, since devices do
   // not append their own addresses to "X-Forwarded-For".
 
   $discard_addresses = ($layers - 1);
 
   // However, we don't want to throw away all of the addresses. Some requests
   // may originate from within the network, and may thus not have as many
   // addresses as we expect. If we have fewer addresses than trustworthy
   // devices, discard all but one address.
 
   $max_discard = (count($addresses) - 1);
 
   $discard_count = min($discard_addresses, $max_discard);
   if ($discard_count) {
     $addresses = array_slice($addresses, 0, -$discard_count);
   }
 
   $original_address = end($addresses);
   $original_address = trim($original_address);
 
   return $original_address;
 }