diff --git a/resources/sql/autopatches/20150922.drydock.commands.1.sql b/resources/sql/autopatches/20150922.drydock.commands.1.sql
new file mode 100644
--- /dev/null
+++ b/resources/sql/autopatches/20150922.drydock.commands.1.sql
@@ -0,0 +1,10 @@
+CREATE TABLE {$NAMESPACE}_drydock.drydock_command (
+  id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  authorPHID VARBINARY(64) NOT NULL,
+  targetPHID VARBINARY(64) NOT NULL,
+  command VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT},
+  isConsumed BOOL NOT NULL,
+  dateCreated INT UNSIGNED NOT NULL,
+  dateModified INT UNSIGNED NOT NULL,
+  KEY `key_target` (targetPHID, isConsumed)
+) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -817,7 +817,9 @@
     'DrydockBlueprintTransaction' => 'applications/drydock/storage/DrydockBlueprintTransaction.php',
     'DrydockBlueprintTransactionQuery' => 'applications/drydock/query/DrydockBlueprintTransactionQuery.php',
     'DrydockBlueprintViewController' => 'applications/drydock/controller/DrydockBlueprintViewController.php',
+    'DrydockCommand' => 'applications/drydock/storage/DrydockCommand.php',
     'DrydockCommandInterface' => 'applications/drydock/interface/command/DrydockCommandInterface.php',
+    'DrydockCommandQuery' => 'applications/drydock/query/DrydockCommandQuery.php',
     'DrydockConsoleController' => 'applications/drydock/controller/DrydockConsoleController.php',
     'DrydockConstants' => 'applications/drydock/constants/DrydockConstants.php',
     'DrydockController' => 'applications/drydock/controller/DrydockController.php',
@@ -837,6 +839,7 @@
     'DrydockLeaseReleaseController' => 'applications/drydock/controller/DrydockLeaseReleaseController.php',
     'DrydockLeaseSearchEngine' => 'applications/drydock/query/DrydockLeaseSearchEngine.php',
     'DrydockLeaseStatus' => 'applications/drydock/constants/DrydockLeaseStatus.php',
+    'DrydockLeaseUpdateWorker' => 'applications/drydock/worker/DrydockLeaseUpdateWorker.php',
     'DrydockLeaseViewController' => 'applications/drydock/controller/DrydockLeaseViewController.php',
     'DrydockLeaseWorker' => 'applications/drydock/worker/DrydockLeaseWorker.php',
     'DrydockLog' => 'applications/drydock/storage/DrydockLog.php',
@@ -845,22 +848,25 @@
     'DrydockLogListView' => 'applications/drydock/view/DrydockLogListView.php',
     'DrydockLogQuery' => 'applications/drydock/query/DrydockLogQuery.php',
     'DrydockLogSearchEngine' => 'applications/drydock/query/DrydockLogSearchEngine.php',
-    'DrydockManagementCloseWorkflow' => 'applications/drydock/management/DrydockManagementCloseWorkflow.php',
     'DrydockManagementCommandWorkflow' => 'applications/drydock/management/DrydockManagementCommandWorkflow.php',
     'DrydockManagementLeaseWorkflow' => 'applications/drydock/management/DrydockManagementLeaseWorkflow.php',
-    'DrydockManagementReleaseWorkflow' => 'applications/drydock/management/DrydockManagementReleaseWorkflow.php',
+    'DrydockManagementReleaseLeaseWorkflow' => 'applications/drydock/management/DrydockManagementReleaseLeaseWorkflow.php',
+    'DrydockManagementReleaseResourceWorkflow' => 'applications/drydock/management/DrydockManagementReleaseResourceWorkflow.php',
+    'DrydockManagementUpdateLeaseWorkflow' => 'applications/drydock/management/DrydockManagementUpdateLeaseWorkflow.php',
+    'DrydockManagementUpdateResourceWorkflow' => 'applications/drydock/management/DrydockManagementUpdateResourceWorkflow.php',
     'DrydockManagementWorkflow' => 'applications/drydock/management/DrydockManagementWorkflow.php',
     'DrydockQuery' => 'applications/drydock/query/DrydockQuery.php',
     'DrydockResource' => 'applications/drydock/storage/DrydockResource.php',
-    'DrydockResourceCloseController' => 'applications/drydock/controller/DrydockResourceCloseController.php',
     'DrydockResourceController' => 'applications/drydock/controller/DrydockResourceController.php',
     'DrydockResourceDatasource' => 'applications/drydock/typeahead/DrydockResourceDatasource.php',
     'DrydockResourceListController' => 'applications/drydock/controller/DrydockResourceListController.php',
     'DrydockResourceListView' => 'applications/drydock/view/DrydockResourceListView.php',
     'DrydockResourcePHIDType' => 'applications/drydock/phid/DrydockResourcePHIDType.php',
     'DrydockResourceQuery' => 'applications/drydock/query/DrydockResourceQuery.php',
+    'DrydockResourceReleaseController' => 'applications/drydock/controller/DrydockResourceReleaseController.php',
     'DrydockResourceSearchEngine' => 'applications/drydock/query/DrydockResourceSearchEngine.php',
     'DrydockResourceStatus' => 'applications/drydock/constants/DrydockResourceStatus.php',
+    'DrydockResourceUpdateWorker' => 'applications/drydock/worker/DrydockResourceUpdateWorker.php',
     'DrydockResourceViewController' => 'applications/drydock/controller/DrydockResourceViewController.php',
     'DrydockResourceWorker' => 'applications/drydock/worker/DrydockResourceWorker.php',
     'DrydockSFTPFilesystemInterface' => 'applications/drydock/interface/filesystem/DrydockSFTPFilesystemInterface.php',
@@ -4535,7 +4541,12 @@
     'DrydockBlueprintTransaction' => 'PhabricatorApplicationTransaction',
     'DrydockBlueprintTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
     'DrydockBlueprintViewController' => 'DrydockBlueprintController',
+    'DrydockCommand' => array(
+      'DrydockDAO',
+      'PhabricatorPolicyInterface',
+    ),
     'DrydockCommandInterface' => 'DrydockInterface',
+    'DrydockCommandQuery' => 'DrydockQuery',
     'DrydockConsoleController' => 'DrydockController',
     'DrydockConstants' => 'Phobject',
     'DrydockController' => 'PhabricatorController',
@@ -4558,6 +4569,7 @@
     'DrydockLeaseReleaseController' => 'DrydockLeaseController',
     'DrydockLeaseSearchEngine' => 'PhabricatorApplicationSearchEngine',
     'DrydockLeaseStatus' => 'DrydockConstants',
+    'DrydockLeaseUpdateWorker' => 'DrydockWorker',
     'DrydockLeaseViewController' => 'DrydockLeaseController',
     'DrydockLeaseWorker' => 'DrydockWorker',
     'DrydockLog' => array(
@@ -4569,25 +4581,28 @@
     'DrydockLogListView' => 'AphrontView',
     'DrydockLogQuery' => 'DrydockQuery',
     'DrydockLogSearchEngine' => 'PhabricatorApplicationSearchEngine',
-    'DrydockManagementCloseWorkflow' => 'DrydockManagementWorkflow',
     'DrydockManagementCommandWorkflow' => 'DrydockManagementWorkflow',
     'DrydockManagementLeaseWorkflow' => 'DrydockManagementWorkflow',
-    'DrydockManagementReleaseWorkflow' => 'DrydockManagementWorkflow',
+    'DrydockManagementReleaseLeaseWorkflow' => 'DrydockManagementWorkflow',
+    'DrydockManagementReleaseResourceWorkflow' => 'DrydockManagementWorkflow',
+    'DrydockManagementUpdateLeaseWorkflow' => 'DrydockManagementWorkflow',
+    'DrydockManagementUpdateResourceWorkflow' => 'DrydockManagementWorkflow',
     'DrydockManagementWorkflow' => 'PhabricatorManagementWorkflow',
     'DrydockQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
     'DrydockResource' => array(
       'DrydockDAO',
       'PhabricatorPolicyInterface',
     ),
-    'DrydockResourceCloseController' => 'DrydockResourceController',
     'DrydockResourceController' => 'DrydockController',
     'DrydockResourceDatasource' => 'PhabricatorTypeaheadDatasource',
     'DrydockResourceListController' => 'DrydockResourceController',
     'DrydockResourceListView' => 'AphrontView',
     'DrydockResourcePHIDType' => 'PhabricatorPHIDType',
     'DrydockResourceQuery' => 'DrydockQuery',
+    'DrydockResourceReleaseController' => 'DrydockResourceController',
     'DrydockResourceSearchEngine' => 'PhabricatorApplicationSearchEngine',
     'DrydockResourceStatus' => 'DrydockConstants',
+    'DrydockResourceUpdateWorker' => 'DrydockWorker',
     'DrydockResourceViewController' => 'DrydockResourceController',
     'DrydockResourceWorker' => 'DrydockWorker',
     'DrydockSFTPFilesystemInterface' => 'DrydockFilesystemInterface',
diff --git a/src/applications/drydock/application/PhabricatorDrydockApplication.php b/src/applications/drydock/application/PhabricatorDrydockApplication.php
--- a/src/applications/drydock/application/PhabricatorDrydockApplication.php
+++ b/src/applications/drydock/application/PhabricatorDrydockApplication.php
@@ -55,8 +55,10 @@
         ),
         'resource/' => array(
           '(?:query/(?P<queryKey>[^/]+)/)?' => 'DrydockResourceListController',
-          '(?P<id>[1-9]\d*)/' => 'DrydockResourceViewController',
-          '(?P<id>[1-9]\d*)/close/' => 'DrydockResourceCloseController',
+          '(?P<id>[1-9]\d*)/' => array(
+            '' => 'DrydockResourceViewController',
+            'release/' => 'DrydockResourceReleaseController',
+          ),
         ),
         'lease/' => array(
           '(?:query/(?P<queryKey>[^/]+)/)?' => 'DrydockLeaseListController',
diff --git a/src/applications/drydock/controller/DrydockController.php b/src/applications/drydock/controller/DrydockController.php
--- a/src/applications/drydock/controller/DrydockController.php
+++ b/src/applications/drydock/controller/DrydockController.php
@@ -36,4 +36,53 @@
       ->addRawContent($table);
   }
 
+  protected function buildCommandsTab($target_phid) {
+    $viewer = $this->getViewer();
+
+    $commands = id(new DrydockCommandQuery())
+      ->setViewer($viewer)
+      ->withTargetPHIDs(array($target_phid))
+      ->execute();
+
+    $consumed_yes = id(new PHUIIconView())
+      ->setIconFont('fa-check green');
+    $consumed_no = id(new PHUIIconView())
+      ->setIconFont('fa-clock-o grey');
+
+    $rows = array();
+    foreach ($commands as $command) {
+      $rows[] = array(
+        $command->getID(),
+        $viewer->renderHandle($command->getAuthorPHID()),
+        $command->getCommand(),
+        ($command->getIsConsumed()
+          ? $consumed_yes
+          : $consumed_no),
+        phabricator_datetime($command->getDateCreated(), $viewer),
+      );
+    }
+
+    $table = id(new AphrontTableView($rows))
+      ->setNoDataString(pht('No commands issued.'))
+      ->setHeaders(
+        array(
+          pht('ID'),
+          pht('From'),
+          pht('Command'),
+          null,
+          pht('Date'),
+        ))
+      ->setColumnClasses(
+        array(
+          null,
+          null,
+          'wide',
+          null,
+          null,
+        ));
+
+    return id(new PHUIPropertyListView())
+      ->addRawContent($table);
+  }
+
 }
diff --git a/src/applications/drydock/controller/DrydockLeaseReleaseController.php b/src/applications/drydock/controller/DrydockLeaseReleaseController.php
--- a/src/applications/drydock/controller/DrydockLeaseReleaseController.php
+++ b/src/applications/drydock/controller/DrydockLeaseReleaseController.php
@@ -9,6 +9,11 @@
     $lease = id(new DrydockLeaseQuery())
       ->setViewer($viewer)
       ->withIDs(array($id))
+      ->requireCapabilities(
+        array(
+          PhabricatorPolicyCapability::CAN_VIEW,
+          PhabricatorPolicyCapability::CAN_EDIT,
+        ))
       ->executeOne();
     if (!$lease) {
       return new Aphront404Response();
@@ -17,43 +22,35 @@
     $lease_uri = '/lease/'.$lease->getID().'/';
     $lease_uri = $this->getApplicationURI($lease_uri);
 
-    if ($lease->getStatus() != DrydockLeaseStatus::STATUS_ACTIVE) {
-      $dialog = id(new AphrontDialogView())
-        ->setUser($viewer)
-        ->setTitle(pht('Lease Not Active'))
-        ->appendChild(
-          phutil_tag(
-            'p',
-            array(),
-            pht('You can only release "active" leases.')))
+    if (!$lease->canRelease()) {
+      return $this->newDialog()
+        ->setTitle(pht('Lease Not Releasable'))
+        ->appendParagraph(
+          pht(
+            'Leases can not be released after they are destroyed.'))
         ->addCancelButton($lease_uri);
-
-      return id(new AphrontDialogResponse())->setDialog($dialog);
     }
 
-    if (!$request->isDialogFormPost()) {
-      $dialog = id(new AphrontDialogView())
-        ->setUser($viewer)
-        ->setTitle(pht('Really release lease?'))
-        ->appendChild(
-          phutil_tag(
-            'p',
-            array(),
-            pht(
-              'Releasing a lease may cause trouble for the lease holder and '.
-              'trigger cleanup of the underlying resource. It can not be '.
-              'undone. Continue?')))
-        ->addSubmitButton(pht('Release Lease'))
-        ->addCancelButton($lease_uri);
+    if ($request->isFormPost()) {
+      $command = DrydockCommand::initializeNewCommand($viewer)
+        ->setTargetPHID($lease->getPHID())
+        ->setCommand(DrydockCommand::COMMAND_RELEASE)
+        ->save();
 
-      return id(new AphrontDialogResponse())->setDialog($dialog);
-    }
+      $lease->scheduleUpdate();
 
-    $resource = $lease->getResource();
-    $blueprint = $resource->getBlueprint();
-    $blueprint->releaseLease($resource, $lease);
+      return id(new AphrontRedirectResponse())->setURI($lease_uri);
+    }
 
-    return id(new AphrontReloadResponse())->setURI($lease_uri);
+    return $this->newDialog()
+      ->setTitle(pht('Release Lease?'))
+      ->appendParagraph(
+        pht(
+          'Forcefully releasing a lease may interfere with the operation '.
+          'of the lease holder and trigger destruction of the underlying '.
+          'resource. It can not be undone.'))
+      ->addSubmitButton(pht('Release Lease'))
+      ->addCancelButton($lease_uri);
   }
 
 }
diff --git a/src/applications/drydock/controller/DrydockLeaseViewController.php b/src/applications/drydock/controller/DrydockLeaseViewController.php
--- a/src/applications/drydock/controller/DrydockLeaseViewController.php
+++ b/src/applications/drydock/controller/DrydockLeaseViewController.php
@@ -43,11 +43,13 @@
     $crumbs->addTextCrumb($title, $lease_uri);
 
     $locks = $this->buildLocksTab($lease->getPHID());
+    $commands = $this->buildCommandsTab($lease->getPHID());
 
     $object_box = id(new PHUIObjectBoxView())
       ->setHeader($header)
       ->addPropertyList($properties, pht('Properties'))
-      ->addPropertyList($locks, pht('Slot Locks'));
+      ->addPropertyList($locks, pht('Slot Locks'))
+      ->addPropertyList($commands, pht('Commands'));
 
     $log_box = id(new PHUIObjectBoxView())
       ->setHeaderText(pht('Lease Logs'))
@@ -66,14 +68,20 @@
   }
 
   private function buildActionListView(DrydockLease $lease) {
+    $viewer = $this->getViewer();
+
     $view = id(new PhabricatorActionListView())
-      ->setUser($this->getRequest()->getUser())
+      ->setUser($viewer)
       ->setObjectURI($this->getRequest()->getRequestURI())
       ->setObject($lease);
 
     $id = $lease->getID();
 
-    $can_release = ($lease->getStatus() == DrydockLeaseStatus::STATUS_ACTIVE);
+    $can_release = $lease->canRelease();
+    $can_edit = PhabricatorPolicyFilter::hasCapability(
+      $viewer,
+      $lease,
+      PhabricatorPolicyCapability::CAN_EDIT);
 
     $view->addAction(
       id(new PhabricatorActionView())
@@ -81,7 +89,7 @@
         ->setIcon('fa-times')
         ->setHref($this->getApplicationURI("/lease/{$id}/release/"))
         ->setWorkflow(true)
-        ->setDisabled(!$can_release));
+        ->setDisabled(!$can_release || !$can_edit));
 
     return $view;
   }
diff --git a/src/applications/drydock/controller/DrydockResourceCloseController.php b/src/applications/drydock/controller/DrydockResourceCloseController.php
deleted file mode 100644
--- a/src/applications/drydock/controller/DrydockResourceCloseController.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-
-final class DrydockResourceCloseController extends DrydockResourceController {
-
-  public function handleRequest(AphrontRequest $request) {
-    $viewer = $request->getViewer();
-    $id = $request->getURIData('id');
-
-    $resource = id(new DrydockResourceQuery())
-      ->setViewer($viewer)
-      ->withIDs(array($id))
-      ->executeOne();
-    if (!$resource) {
-      return new Aphront404Response();
-    }
-
-    $resource_uri = '/resource/'.$resource->getID().'/';
-    $resource_uri = $this->getApplicationURI($resource_uri);
-
-    if ($resource->getStatus() != DrydockResourceStatus::STATUS_OPEN) {
-      $dialog = id(new AphrontDialogView())
-        ->setUser($viewer)
-        ->setTitle(pht('Resource Not Open'))
-        ->appendChild(phutil_tag('p', array(), pht(
-          'You can only close "open" resources.')))
-        ->addCancelButton($resource_uri);
-
-      return id(new AphrontDialogResponse())->setDialog($dialog);
-    }
-
-    if ($request->isFormPost()) {
-      $resource->closeResource();
-      return id(new AphrontReloadResponse())->setURI($resource_uri);
-    }
-
-    $dialog = id(new AphrontDialogView())
-      ->setUser($viewer)
-      ->setTitle(pht('Really close resource?'))
-      ->appendChild(
-        pht(
-          'Closing a resource releases all leases and destroys the '.
-          'resource. It can not be undone. Continue?'))
-      ->addSubmitButton(pht('Close Resource'))
-      ->addCancelButton($resource_uri);
-
-    return id(new AphrontDialogResponse())->setDialog($dialog);
-  }
-
-}
diff --git a/src/applications/drydock/controller/DrydockResourceReleaseController.php b/src/applications/drydock/controller/DrydockResourceReleaseController.php
new file mode 100644
--- /dev/null
+++ b/src/applications/drydock/controller/DrydockResourceReleaseController.php
@@ -0,0 +1,56 @@
+<?php
+
+final class DrydockResourceReleaseController extends DrydockResourceController {
+
+  public function handleRequest(AphrontRequest $request) {
+    $viewer = $request->getViewer();
+    $id = $request->getURIData('id');
+
+    $resource = id(new DrydockResourceQuery())
+      ->setViewer($viewer)
+      ->withIDs(array($id))
+      ->requireCapabilities(
+        array(
+          PhabricatorPolicyCapability::CAN_VIEW,
+          PhabricatorPolicyCapability::CAN_EDIT,
+        ))
+      ->executeOne();
+    if (!$resource) {
+      return new Aphront404Response();
+    }
+
+    $resource_uri = '/resource/'.$resource->getID().'/';
+    $resource_uri = $this->getApplicationURI($resource_uri);
+
+    if (!$resource->canRelease()) {
+      return $this->newDialog()
+        ->setTitle(pht('Resource Not Releasable'))
+        ->appendParagraph(
+          pht(
+            'Resources can not be released after they are destroyed.'))
+        ->addCancelButton($resource_uri);
+    }
+
+    if ($request->isFormPost()) {
+      $command = DrydockCommand::initializeNewCommand($viewer)
+        ->setTargetPHID($resource->getPHID())
+        ->setCommand(DrydockCommand::COMMAND_RELEASE)
+        ->save();
+
+      $resource->scheduleUpdate();
+
+      return id(new AphrontRedirectResponse())->setURI($resource_uri);
+    }
+
+
+    return $this->newDialog()
+      ->setTitle(pht('Really release resource?'))
+      ->appendChild(
+        pht(
+          'Releasing a resource releases all leases and destroys the '.
+          'resource. It can not be undone.'))
+      ->addSubmitButton(pht('Release Resource'))
+      ->addCancelButton($resource_uri);
+  }
+
+}
diff --git a/src/applications/drydock/controller/DrydockResourceViewController.php b/src/applications/drydock/controller/DrydockResourceViewController.php
--- a/src/applications/drydock/controller/DrydockResourceViewController.php
+++ b/src/applications/drydock/controller/DrydockResourceViewController.php
@@ -55,11 +55,13 @@
     $crumbs->addTextCrumb(pht('Resource %d', $resource->getID()));
 
     $locks = $this->buildLocksTab($resource->getPHID());
+    $commands = $this->buildCommandsTab($resource->getPHID());
 
     $object_box = id(new PHUIObjectBoxView())
       ->setHeader($header)
       ->addPropertyList($properties, pht('Properties'))
-      ->addPropertyList($locks, pht('Slot Locks'));
+      ->addPropertyList($locks, pht('Slot Locks'))
+      ->addPropertyList($commands, pht('Commands'));
 
     $lease_box = id(new PHUIObjectBoxView())
       ->setHeaderText(pht('Leases'))
@@ -83,22 +85,29 @@
   }
 
   private function buildActionListView(DrydockResource $resource) {
+    $viewer = $this->getViewer();
+
     $view = id(new PhabricatorActionListView())
-      ->setUser($this->getRequest()->getUser())
+      ->setUser($viewer)
       ->setObjectURI($this->getRequest()->getRequestURI())
       ->setObject($resource);
 
-    $can_close = ($resource->getStatus() == DrydockResourceStatus::STATUS_OPEN);
-    $uri = '/resource/'.$resource->getID().'/close/';
+    $can_release = $resource->canRelease();
+    $can_edit = PhabricatorPolicyFilter::hasCapability(
+      $viewer,
+      $resource,
+      PhabricatorPolicyCapability::CAN_EDIT);
+
+    $uri = '/resource/'.$resource->getID().'/release/';
     $uri = $this->getApplicationURI($uri);
 
     $view->addAction(
       id(new PhabricatorActionView())
         ->setHref($uri)
-        ->setName(pht('Close Resource'))
+        ->setName(pht('Release Resource'))
         ->setIcon('fa-times')
         ->setWorkflow(true)
-        ->setDisabled(!$can_close));
+        ->setDisabled(!$can_release || !$can_edit));
 
     return $view;
   }
diff --git a/src/applications/drydock/management/DrydockManagementCloseWorkflow.php b/src/applications/drydock/management/DrydockManagementCloseWorkflow.php
deleted file mode 100644
--- a/src/applications/drydock/management/DrydockManagementCloseWorkflow.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-
-final class DrydockManagementCloseWorkflow
-  extends DrydockManagementWorkflow {
-
-  protected function didConstruct() {
-    $this
-      ->setName('close')
-      ->setSynopsis(pht('Close a resource.'))
-      ->setArguments(
-        array(
-          array(
-            'name'      => 'ids',
-            'wildcard'  => true,
-          ),
-        ));
-  }
-
-  public function execute(PhutilArgumentParser $args) {
-    $console = PhutilConsole::getConsole();
-
-    $ids = $args->getArg('ids');
-    if (!$ids) {
-      throw new PhutilArgumentUsageException(
-        pht('Specify one or more resource IDs to close.'));
-    }
-
-    $viewer = $this->getViewer();
-
-    $resources = id(new DrydockResourceQuery())
-      ->setViewer($viewer)
-      ->withIDs($ids)
-      ->execute();
-
-    foreach ($ids as $id) {
-      $resource = idx($resources, $id);
-      if (!$resource) {
-        $console->writeErr("%s\n", pht('Resource %d does not exist!', $id));
-      } else if ($resource->getStatus() != DrydockResourceStatus::STATUS_OPEN) {
-        $console->writeErr("%s\n", pht("Resource %d is not 'open'!", $id));
-      } else {
-        $resource->closeResource();
-        $console->writeErr("%s\n", pht('Closed resource %d.', $id));
-      }
-    }
-
-  }
-
-}
diff --git a/src/applications/drydock/management/DrydockManagementReleaseLeaseWorkflow.php b/src/applications/drydock/management/DrydockManagementReleaseLeaseWorkflow.php
new file mode 100644
--- /dev/null
+++ b/src/applications/drydock/management/DrydockManagementReleaseLeaseWorkflow.php
@@ -0,0 +1,70 @@
+<?php
+
+final class DrydockManagementReleaseLeaseWorkflow
+  extends DrydockManagementWorkflow {
+
+  protected function didConstruct() {
+    $this
+      ->setName('release-lease')
+      ->setSynopsis(pht('Release a lease.'))
+      ->setArguments(
+        array(
+          array(
+            'name' => 'id',
+            'param' => 'id',
+            'repeat' => true,
+            'help' => pht('Lease ID to release.'),
+          ),
+        ));
+  }
+
+  public function execute(PhutilArgumentParser $args) {
+    $ids = $args->getArg('id');
+    if (!$ids) {
+      throw new PhutilArgumentUsageException(
+        pht(
+          'Specify one or more lease IDs to release with "%s".',
+          '--id'));
+    }
+
+    $viewer = $this->getViewer();
+    $drydock_phid = id(new PhabricatorDrydockApplication())->getPHID();
+
+    $leases = id(new DrydockLeaseQuery())
+      ->setViewer($viewer)
+      ->withIDs($ids)
+      ->execute();
+
+    PhabricatorWorker::setRunAllTasksInProcess(true);
+    foreach ($ids as $id) {
+      $lease = idx($leases, $id);
+      if (!$lease) {
+        echo tsprintf(
+          "%s\n",
+          pht('Lease "%s" does not exist.', $id));
+        continue;
+      }
+
+      if (!$lease->canRelease()) {
+        echo tsprintf(
+          "%s\n",
+          pht('Lease "%s" is not releasable.', $id));
+        continue;
+      }
+
+      $command = DrydockCommand::initializeNewCommand($viewer)
+        ->setTargetPHID($lease->getPHID())
+        ->setAuthorPHID($drydock_phid)
+        ->setCommand(DrydockCommand::COMMAND_RELEASE)
+        ->save();
+
+      $lease->scheduleUpdate();
+
+      echo tsprintf(
+        "%s\n",
+        pht('Scheduled release of lease "%s".', $id));
+    }
+
+  }
+
+}
diff --git a/src/applications/drydock/management/DrydockManagementReleaseResourceWorkflow.php b/src/applications/drydock/management/DrydockManagementReleaseResourceWorkflow.php
new file mode 100644
--- /dev/null
+++ b/src/applications/drydock/management/DrydockManagementReleaseResourceWorkflow.php
@@ -0,0 +1,71 @@
+<?php
+
+final class DrydockManagementReleaseResourceWorkflow
+  extends DrydockManagementWorkflow {
+
+  protected function didConstruct() {
+    $this
+      ->setName('release-resource')
+      ->setSynopsis(pht('Release a resource.'))
+      ->setArguments(
+        array(
+          array(
+            'name' => 'id',
+            'param' => 'id',
+            'repeat' => true,
+            'help' => pht('Resource ID to release.'),
+          ),
+        ));
+  }
+
+  public function execute(PhutilArgumentParser $args) {
+    $ids = $args->getArg('id');
+    if (!$ids) {
+      throw new PhutilArgumentUsageException(
+        pht(
+          'Specify one or more resource IDs to release with "%s".',
+          '--id'));
+    }
+
+    $viewer = $this->getViewer();
+    $drydock_phid = id(new PhabricatorDrydockApplication())->getPHID();
+
+    $resources = id(new DrydockResourceQuery())
+      ->setViewer($viewer)
+      ->withIDs($ids)
+      ->execute();
+
+    PhabricatorWorker::setRunAllTasksInProcess(true);
+    foreach ($ids as $id) {
+      $resource = idx($resources, $id);
+
+      if (!$resource) {
+        echo tsprintf(
+          "%s\n",
+          pht('Resource "%s" does not exist.', $id));
+        continue;
+      }
+
+      if (!$resource->canRelease()) {
+        echo tsprintf(
+          "%s\n",
+          pht('Resource "%s" is not releasable.', $id));
+        continue;
+      }
+
+      $command = DrydockCommand::initializeNewCommand($viewer)
+        ->setTargetPHID($resource->getPHID())
+        ->setAuthorPHID($drydock_phid)
+        ->setCommand(DrydockCommand::COMMAND_RELEASE)
+        ->save();
+
+      $resource->scheduleUpdate();
+
+      echo tsprintf(
+        "%s\n",
+        pht('Scheduled release of resource "%s".', $id));
+    }
+
+  }
+
+}
diff --git a/src/applications/drydock/management/DrydockManagementReleaseWorkflow.php b/src/applications/drydock/management/DrydockManagementReleaseWorkflow.php
deleted file mode 100644
--- a/src/applications/drydock/management/DrydockManagementReleaseWorkflow.php
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-
-final class DrydockManagementReleaseWorkflow
-  extends DrydockManagementWorkflow {
-
-  protected function didConstruct() {
-    $this
-      ->setName('release')
-      ->setSynopsis(pht('Release a lease.'))
-      ->setArguments(
-        array(
-          array(
-            'name'      => 'ids',
-            'wildcard'  => true,
-          ),
-        ));
-  }
-
-  public function execute(PhutilArgumentParser $args) {
-    $console = PhutilConsole::getConsole();
-
-    $ids = $args->getArg('ids');
-    if (!$ids) {
-      throw new PhutilArgumentUsageException(
-        pht('Specify one or more lease IDs to release.'));
-    }
-
-    $viewer = $this->getViewer();
-
-    $leases = id(new DrydockLeaseQuery())
-      ->setViewer($viewer)
-      ->withIDs($ids)
-      ->execute();
-
-    foreach ($ids as $id) {
-      $lease = idx($leases, $id);
-      if (!$lease) {
-        $console->writeErr("%s\n", pht('Lease %d does not exist!', $id));
-      } else if ($lease->getStatus() != DrydockLeaseStatus::STATUS_ACTIVE) {
-        $console->writeErr("%s\n", pht("Lease %d is not 'active'!", $id));
-      } else {
-        $resource = $lease->getResource();
-        $blueprint = $resource->getBlueprint();
-        $blueprint->releaseLease($resource, $lease);
-
-        $console->writeErr("%s\n", pht('Released lease %d.', $id));
-      }
-    }
-
-  }
-
-}
diff --git a/src/applications/drydock/management/DrydockManagementUpdateLeaseWorkflow.php b/src/applications/drydock/management/DrydockManagementUpdateLeaseWorkflow.php
new file mode 100644
--- /dev/null
+++ b/src/applications/drydock/management/DrydockManagementUpdateLeaseWorkflow.php
@@ -0,0 +1,57 @@
+<?php
+
+final class DrydockManagementUpdateLeaseWorkflow
+  extends DrydockManagementWorkflow {
+
+  protected function didConstruct() {
+    $this
+      ->setName('update-lease')
+      ->setSynopsis(pht('Update a lease.'))
+      ->setArguments(
+        array(
+          array(
+            'name' => 'id',
+            'param' => 'id',
+            'repeat' => true,
+            'help' => pht('Lease ID to update.'),
+          ),
+        ));
+  }
+
+  public function execute(PhutilArgumentParser $args) {
+    $viewer = $this->getViewer();
+
+    $ids = $args->getArg('id');
+    if (!$ids) {
+      throw new PhutilArgumentUsageException(
+        pht(
+          'Specify one or more lease IDs to update with "%s".',
+          '--id'));
+    }
+
+    $leases = id(new DrydockLeaseQuery())
+      ->setViewer($viewer)
+      ->withIDs($ids)
+      ->execute();
+
+    PhabricatorWorker::setRunAllTasksInProcess(true);
+
+    foreach ($ids as $id) {
+      $lease = idx($leases, $id);
+
+      if (!$lease) {
+        echo tsprintf(
+          "%s\n",
+          pht('Lease "%s" does not exist.', $id));
+        continue;
+      }
+
+      echo tsprintf(
+        "%s\n",
+        pht('Updating lease "%s".', $id));
+
+      $lease->scheduleUpdate();
+    }
+  }
+
+}
diff --git a/src/applications/drydock/management/DrydockManagementUpdateResourceWorkflow.php b/src/applications/drydock/management/DrydockManagementUpdateResourceWorkflow.php
new file mode 100644
--- /dev/null
+++ b/src/applications/drydock/management/DrydockManagementUpdateResourceWorkflow.php
@@ -0,0 +1,58 @@
+<?php
+
+final class DrydockManagementUpdateResourceWorkflow
+  extends DrydockManagementWorkflow {
+
+  protected function didConstruct() {
+    $this
+      ->setName('update-resource')
+      ->setSynopsis(pht('Update a resource.'))
+      ->setArguments(
+        array(
+          array(
+            'name' => 'id',
+            'param' => 'id',
+            'repeat' => true,
+            'help' => pht('Resource ID to update.'),
+          ),
+        ));
+  }
+
+  public function execute(PhutilArgumentParser $args) {
+    $viewer = $this->getViewer();
+
+    $ids = $args->getArg('id');
+    if (!$ids) {
+      throw new PhutilArgumentUsageException(
+        pht(
+          'Specify one or more resource IDs to update with "%s".',
+          '--id'));
+    }
+
+    $resources = id(new DrydockResourceQuery())
+      ->setViewer($viewer)
+      ->withIDs($ids)
+      ->execute();
+
+    PhabricatorWorker::setRunAllTasksInProcess(true);
+
+    foreach ($ids as $id) {
+      $resource = idx($resources, $id);
+
+      if (!$resource) {
+        echo tsprintf(
+          "%s\n",
+          pht('Resource "%s" does not exist.', $id));
+        continue;
+      }
+
+      echo tsprintf(
+        "%s\n",
+        pht('Updating resource "%s".', $id));
+
+      $resource->scheduleUpdate();
+    }
+
+  }
+
+}
diff --git a/src/applications/drydock/query/DrydockCommandQuery.php b/src/applications/drydock/query/DrydockCommandQuery.php
new file mode 100644
--- /dev/null
+++ b/src/applications/drydock/query/DrydockCommandQuery.php
@@ -0,0 +1,82 @@
+<?php
+
+final class DrydockCommandQuery extends DrydockQuery {
+
+  private $ids;
+  private $targetPHIDs;
+  private $consumed;
+
+  public function withIDs(array $ids) {
+    $this->ids = $ids;
+    return $this;
+  }
+
+  public function withTargetPHIDs(array $phids) {
+    $this->targetPHIDs = $phids;
+    return $this;
+  }
+
+  public function withConsumed($consumed) {
+    $this->consumed = $consumed;
+    return $this;
+  }
+
+  public function newResultObject() {
+    return new DrydockCommand();
+  }
+
+  protected function loadPage() {
+    return $this->loadStandardPage($this->newResultObject());
+  }
+
+  protected function willFilterPage(array $commands) {
+    $target_phids = mpull($commands, 'getTargetPHID');
+
+    $targets = id(new PhabricatorObjectQuery())
+      ->setViewer($this->getViewer())
+      ->setParentQuery($this)
+      ->withPHIDs($target_phids)
+      ->execute();
+    $targets = mpull($targets, null, 'getPHID');
+
+    foreach ($commands as $key => $command) {
+      $target = idx($targets, $command->getTargetPHID());
+      if (!$target) {
+        $this->didRejectResult($command);
+        unset($commands[$key]);
+        continue;
+      }
+      $command->attachCommandTarget($target);
+    }
+
+    return $commands;
+  }
+
+  protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
+    $where = parent::buildWhereClauseParts($conn);
+
+    if ($this->ids !== null) {
+      $where[] = qsprintf(
+        $conn,
+        'id IN (%Ld)',
+        $this->ids);
+    }
+
+    if ($this->targetPHIDs !== null) {
+      $where[] = qsprintf(
+        $conn,
+        'targetPHID IN (%Ls)',
+        $this->targetPHIDs);
+    }
+
+    if ($this->consumed !== null) {
+      $where[] = qsprintf(
+        $conn,
+        'isConsumed = %d',
+        (int)$this->consumed);
+    }
+
+    return $where;
+  }
+
+}
diff --git a/src/applications/drydock/query/DrydockLeaseQuery.php b/src/applications/drydock/query/DrydockLeaseQuery.php
--- a/src/applications/drydock/query/DrydockLeaseQuery.php
+++ b/src/applications/drydock/query/DrydockLeaseQuery.php
@@ -7,6 +7,7 @@
   private $resourceIDs;
   private $statuses;
   private $datasourceQuery;
+  private $needCommands;
 
   public function withIDs(array $ids) {
     $this->ids = $ids;
diff --git a/src/applications/drydock/storage/DrydockCommand.php b/src/applications/drydock/storage/DrydockCommand.php
new file mode 100644
--- /dev/null
+++ b/src/applications/drydock/storage/DrydockCommand.php
@@ -0,0 +1,69 @@
+<?php
+
+final class DrydockCommand
+  extends DrydockDAO
+  implements PhabricatorPolicyInterface {
+
+  const COMMAND_RELEASE = 'release';
+
+  protected $authorPHID;
+  protected $targetPHID;
+  protected $command;
+  protected $isConsumed;
+
+  private $commandTarget = self::ATTACHABLE;
+
+  public static function initializeNewCommand(PhabricatorUser $author) {
+    return id(new DrydockCommand())
+      ->setAuthorPHID($author->getPHID())
+      ->setIsConsumed(0);
+  }
+
+  protected function getConfiguration() {
+    return array(
+      self::CONFIG_COLUMN_SCHEMA => array(
+        'command' => 'text32',
+        'isConsumed' => 'bool',
+      ),
+      self::CONFIG_KEY_SCHEMA => array(
+        'key_target' => array(
+          'columns' => array('targetPHID', 'isConsumed'),
+        ),
+      ),
+    ) + parent::getConfiguration();
+  }
+
+  public function attachCommandTarget($target) {
+    $this->commandTarget = $target;
+    return $this;
+  }
+
+  public function getCommandTarget() {
+    return $this->assertAttached($this->commandTarget);
+  }
+
+
+/* -(  PhabricatorPolicyInterface  )----------------------------------------- */
+
+
+  public function getCapabilities() {
+    return array(
+      PhabricatorPolicyCapability::CAN_VIEW,
+    );
+  }
+
+  public function getPolicy($capability) {
+    return $this->getCommandTarget()->getPolicy($capability);
+  }
+
+  public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
+    return $this->getCommandTarget()->hasAutomaticCapability(
+      $capability,
+      $viewer);
+  }
+
+  public function describeAutomaticCapability($capability) {
+    return pht('Drydock commands have the same policies as their targets.');
+  }
+
+}
diff --git a/src/applications/drydock/storage/DrydockLease.php b/src/applications/drydock/storage/DrydockLease.php
--- a/src/applications/drydock/storage/DrydockLease.php
+++ b/src/applications/drydock/storage/DrydockLease.php
@@ -30,11 +30,24 @@
   }
 
   public function __destruct() {
-    if ($this->releaseOnDestruction) {
-      if ($this->isActive()) {
-        $this->release();
-      }
+    if (!$this->releaseOnDestruction) {
+      return;
+    }
+
+    if (!$this->canRelease()) {
+      return;
     }
+
+    $actor = PhabricatorUser::getOmnipotentUser();
+    $drydock_phid = id(new PhabricatorDrydockApplication())->getPHID();
+
+    $command = DrydockCommand::initializeNewCommand($actor)
+      ->setTargetPHID($this->getPHID())
+      ->setAuthorPHID($drydock_phid)
+      ->setCommand(DrydockCommand::COMMAND_RELEASE)
+      ->save();
+
+    $this->scheduleUpdate();
   }
 
   public function getLeaseName() {
@@ -130,18 +143,6 @@
     return $this;
   }
 
-  public function release() {
-    $this->assertActive();
-    $this->setStatus(DrydockLeaseStatus::STATUS_RELEASED);
-    $this->save();
-
-    DrydockSlotLock::releaseLocks($this->getPHID());
-
-    $this->resource = null;
-
-    return $this;
-  }
-
   public function isActive() {
     switch ($this->status) {
       case DrydockLeaseStatus::STATUS_ACQUIRED:
@@ -262,6 +263,10 @@
 
     $this->isAcquired = true;
 
+    if ($new_status == DrydockLeaseStatus::STATUS_ACTIVE) {
+      $this->didActivate();
+    }
+
     return $this;
   }
 
@@ -301,6 +306,8 @@
 
     $this->isActivated = true;
 
+    $this->didActivate();
+
     return $this;
   }
 
@@ -308,6 +315,48 @@
     return $this->isActivated;
   }
 
+  public function canRelease() {
+    if (!$this->getID()) {
+      return false;
+    }
+
+    switch ($this->getStatus()) {
+      case DrydockLeaseStatus::STATUS_RELEASED:
+        return false;
+      default:
+        return true;
+    }
+  }
+
+  public function scheduleUpdate() {
+    PhabricatorWorker::scheduleTask(
+      'DrydockLeaseUpdateWorker',
+      array(
+        'leasePHID' => $this->getPHID(),
+      ),
+      array(
+        'objectPHID' => $this->getPHID(),
+      ));
+  }
+
+  private function didActivate() {
+    $viewer = PhabricatorUser::getOmnipotentUser();
+    $need_update = false;
+
+    $commands = id(new DrydockCommandQuery())
+      ->setViewer($viewer)
+      ->withTargetPHIDs(array($this->getPHID()))
+      ->withConsumed(false)
+      ->execute();
+    if ($commands) {
+      $need_update = true;
+    }
+
+    if ($need_update) {
+      $this->scheduleUpdate();
+    }
+  }
+
 
 /* -(  PhabricatorPolicyInterface  )----------------------------------------- */
 
@@ -315,6 +364,7 @@
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
+      PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
@@ -322,6 +372,9 @@
     if ($this->getResource()) {
       return $this->getResource()->getPolicy($capability);
     }
+
+    // TODO: Implement reasonable policies.
+
     return PhabricatorPolicies::getMostOpenPolicy();
   }
 
diff --git a/src/applications/drydock/storage/DrydockResource.php b/src/applications/drydock/storage/DrydockResource.php
--- a/src/applications/drydock/storage/DrydockResource.php
+++ b/src/applications/drydock/storage/DrydockResource.php
@@ -170,43 +170,44 @@
     return $this->isActivated;
   }
 
-  public function closeResource() {
+  public function canRelease() {
+    switch ($this->getStatus()) {
+      case DrydockResourceStatus::STATUS_CLOSED:
+      case DrydockResourceStatus::STATUS_DESTROYED:
+        return false;
+      default:
+        return true;
+    }
+  }
 
-    // TODO: This is super broken and will race other lease writers!
+  public function scheduleUpdate() {
+    PhabricatorWorker::scheduleTask(
+      'DrydockResourceUpdateWorker',
+      array(
+        'resourcePHID' => $this->getPHID(),
+      ),
+      array(
+        'objectPHID' => $this->getPHID(),
+      ));
+  }
 
-    $this->openTransaction();
-      $statuses = array(
-        DrydockLeaseStatus::STATUS_PENDING,
-        DrydockLeaseStatus::STATUS_ACTIVE,
-      );
-
-      $leases = id(new DrydockLeaseQuery())
-        ->setViewer(PhabricatorUser::getOmnipotentUser())
-        ->withResourceIDs(array($this->getID()))
-        ->withStatuses($statuses)
-        ->execute();
-
-      foreach ($leases as $lease) {
-        switch ($lease->getStatus()) {
-          case DrydockLeaseStatus::STATUS_PENDING:
-            $message = pht('Breaking pending lease (resource closing).');
-            $lease->setStatus(DrydockLeaseStatus::STATUS_BROKEN);
-            break;
-          case DrydockLeaseStatus::STATUS_ACTIVE:
-            $message = pht('Releasing active lease (resource closing).');
-            $lease->setStatus(DrydockLeaseStatus::STATUS_RELEASED);
-            break;
-        }
-        DrydockBlueprintImplementation::writeLog($this, $lease, $message);
-        $lease->save();
-      }
-
-      $this->setStatus(DrydockResourceStatus::STATUS_CLOSED);
-      $this->save();
-
-      DrydockSlotLock::releaseLocks($this->getPHID());
+  private function didActivate() {
+    $viewer = PhabricatorUser::getOmnipotentUser();
 
-    $this->saveTransaction();
+    $need_update = false;
+
+    $commands = id(new DrydockCommandQuery())
+      ->setViewer($viewer)
+      ->withTargetPHIDs(array($this->getPHID()))
+      ->withConsumed(false)
+      ->execute();
+    if ($commands) {
+      $need_update = true;
+    }
+
+    if ($need_update) {
+      $this->scheduleUpdate();
+    }
   }
 
 
@@ -216,12 +217,15 @@
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
+      PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
+      case PhabricatorPolicyCapability::CAN_EDIT:
+        // TODO: Implement reasonable policies.
         return PhabricatorPolicies::getMostOpenPolicy();
     }
   }
diff --git a/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php b/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php
new file mode 100644
--- /dev/null
+++ b/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php
@@ -0,0 +1,60 @@
+<?php
+
+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);
+
+    $lease = $this->loadLease($lease_phid);
+    $this->updateLease($lease);
+
+    $lock->unlock();
+  }
+
+  private function updateLease(DrydockLease $lease) {
+    $commands = $this->loadCommands($lease->getPHID());
+    foreach ($commands as $command) {
+      if ($lease->getStatus() != DrydockLeaseStatus::STATUS_ACTIVE) {
+        // Leases can't receive commands before they activate or after they
+        // release.
+        break;
+      }
+
+      $this->processCommand($lease, $command);
+      $command
+        ->setIsConsumed(true)
+        ->save();
+    }
+  }
+
+  private function processCommand(
+    DrydockLease $lease,
+    DrydockCommand $command) {
+    switch ($command->getCommand()) {
+      case DrydockCommand::COMMAND_RELEASE:
+        $this->releaseLease($lease);
+        break;
+    }
+  }
+
+  private function releaseLease(DrydockLease $lease) {
+    $lease->openTransaction();
+      $lease
+        ->setStatus(DrydockLeaseStatus::STATUS_RELEASED)
+        ->save();
+
+      // TODO: Hold slot locks until destruction?
+      DrydockSlotLock::releaseLocks($lease->getPHID());
+    $lease->saveTransaction();
+
+    // TODO: Hook for resource release behaviors.
+    // TODO: Schedule lease destruction.
+  }
+
+}
diff --git a/src/applications/drydock/worker/DrydockResourceUpdateWorker.php b/src/applications/drydock/worker/DrydockResourceUpdateWorker.php
new file mode 100644
--- /dev/null
+++ b/src/applications/drydock/worker/DrydockResourceUpdateWorker.php
@@ -0,0 +1,92 @@
+<?php
+
+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);
+
+    $resource = $this->loadResource($resource_phid);
+    $this->updateResource($resource);
+
+    $lock->unlock();
+  }
+
+  private function updateResource(DrydockResource $resource) {
+    $commands = $this->loadCommands($resource->getPHID());
+    foreach ($commands as $command) {
+      if ($resource->getStatus() != DrydockResourceStatus::STATUS_OPEN) {
+        // Resources can't receive commands before they activate or after they
+        // release.
+        break;
+      }
+
+      $this->processCommand($resource, $command);
+
+      $command
+        ->setIsConsumed(true)
+        ->save();
+    }
+  }
+
+  private function processCommand(
+    DrydockResource $resource,
+    DrydockCommand $command) {
+
+    switch ($command->getCommand()) {
+      case DrydockCommand::COMMAND_RELEASE:
+        $this->releaseResource($resource);
+        break;
+    }
+  }
+
+  private function releaseResource(DrydockResource $resource) {
+    if ($resource->getStatus() != DrydockResourceStatus::STATUS_OPEN) {
+      // If we had multiple release commands
+      // This command is only meaningful to resources in the "Open" state.
+      return;
+    }
+
+    $viewer = $this->getViewer();
+    $drydock_phid = id(new PhabricatorDrydockApplication())->getPHID();
+
+    $resource->openTransaction();
+      $resource
+        ->setStatus(DrydockResourceStatus::STATUS_CLOSED)
+        ->save();
+
+      // TODO: Hold slot locks until destruction?
+      DrydockSlotLock::releaseLocks($resource->getPHID());
+    $resource->saveTransaction();
+
+    $statuses = array(
+      DrydockLeaseStatus::STATUS_PENDING,
+      DrydockLeaseStatus::STATUS_ACQUIRED,
+      DrydockLeaseStatus::STATUS_ACTIVE,
+    );
+
+    $leases = id(new DrydockLeaseQuery())
+      ->setViewer($viewer)
+      ->withResourceIDs(array($resource->getID()))
+      ->withStatuses($statuses)
+      ->execute();
+
+    foreach ($leases as $lease) {
+      $command = DrydockCommand::initializeNewCommand($viewer)
+        ->setTargetPHID($lease->getPHID())
+        ->setAuthorPHID($drydock_phid)
+        ->setCommand(DrydockCommand::COMMAND_RELEASE)
+        ->save();
+
+      $lease->scheduleUpdate();
+    }
+
+    // TODO: Schedule resource destruction.
+  }
+
+}
diff --git a/src/applications/drydock/worker/DrydockWorker.php b/src/applications/drydock/worker/DrydockWorker.php
--- a/src/applications/drydock/worker/DrydockWorker.php
+++ b/src/applications/drydock/worker/DrydockWorker.php
@@ -36,4 +36,18 @@
     return $resource;
   }
 
+  protected function loadCommands($target_phid) {
+    $viewer = $this->getViewer();
+
+    $commands = id(new DrydockCommandQuery())
+      ->setViewer($viewer)
+      ->withTargetPHIDs(array($target_phid))
+      ->withConsumed(false)
+      ->execute();
+
+    $commands = msort($commands, 'getID');
+
+    return $commands;
+  }
+
 }
diff --git a/src/applications/harbormaster/artifact/HarbormasterHostArtifact.php b/src/applications/harbormaster/artifact/HarbormasterHostArtifact.php
--- a/src/applications/harbormaster/artifact/HarbormasterHostArtifact.php
+++ b/src/applications/harbormaster/artifact/HarbormasterHostArtifact.php
@@ -62,13 +62,16 @@
 
   public function releaseArtifact(PhabricatorUser $actor) {
     $lease = $this->loadArtifactLease($actor);
-    $resource = $lease->getResource();
-    $blueprint = $resource->getBlueprint();
-
-    if ($lease->isActive()) {
-      $blueprint->releaseLease($resource, $lease);
+    if (!$lease->canRelease()) {
+      return;
     }
-  }
 
+    $command = DrydockCommand::initializeNewCommand($actor)
+      ->setTargetPHID($lease->getPHID())
+      ->setCommand(DrydockCommand::COMMAND_RELEASE)
+      ->save();
+
+    $lease->scheduleUpdate();
+  }
 
 }