[ARVADOS] created: 1.3.0-979-g15df36132

Git user git at public.curoverse.com
Wed May 22 13:15:46 UTC 2019


        at  15df36132f0f550bc6f7a7840e5471e87f1c13c2 (commit)


commit 15df36132f0f550bc6f7a7840e5471e87f1c13c2
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 21 13:45:38 2019 -0400

    14287: Enable new controller code in integration tests.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/python/tests/run_test_server.py b/sdk/python/tests/run_test_server.py
index e595a298a..c39e590e8 100644
--- a/sdk/python/tests/run_test_server.py
+++ b/sdk/python/tests/run_test_server.py
@@ -413,6 +413,7 @@ def run_controller():
         f.write("""
 Clusters:
   zzzzz:
+    EnableBetaController14287: true
     Logging:
       Level: "{}"
     ManagementToken: e687950a23c3a9bceec28c6223a06c79

commit 0ac1c474b86467375d05635b7804f144ba450e5c
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 21 17:19:12 2019 -0400

    14287: Add collection trash & untrash endpoints.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
index cdebde885..184e2db14 100644
--- a/lib/controller/federation/conn.go
+++ b/lib/controller/federation/conn.go
@@ -29,6 +29,8 @@ type Interface interface {
 	CollectionProvenance(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error)
 	CollectionUsedBy(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error)
 	CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error)
+	CollectionTrash(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error)
+	CollectionUntrash(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error)
 	ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error)
 	ContainerUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Container, error)
 	ContainerGet(ctx context.Context, options arvados.GetOptions) (arvados.Container, error)
@@ -254,6 +256,14 @@ func (conn *Conn) CollectionDelete(ctx context.Context, options arvados.DeleteOp
 	return conn.chooseBackend(options.UUID).CollectionDelete(ctx, options)
 }
 
+func (conn *Conn) CollectionTrash(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
+	return conn.chooseBackend(options.UUID).CollectionTrash(ctx, options)
+}
+
+func (conn *Conn) CollectionUntrash(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
+	return conn.chooseBackend(options.UUID).CollectionUntrash(ctx, options)
+}
+
 func (conn *Conn) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
 	return conn.chooseBackend(options.ClusterID).ContainerCreate(ctx, options)
 }
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index 48a336d9e..efaf24035 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -89,6 +89,20 @@ func (rtr *router) addRoutes(cluster *arvados.Cluster) {
 			},
 		},
 		{
+			arvados.EndpointCollectionTrash,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.CollectionTrash(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointCollectionUntrash,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.CollectionUntrash(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
 			arvados.EndpointContainerCreate,
 			func() interface{} { return &arvados.CreateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
index 4533bfa1e..a48ea7700 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -164,6 +164,20 @@ func (conn *Conn) CollectionDelete(ctx context.Context, options arvados.DeleteOp
 	return resp, err
 }
 
+func (conn *Conn) CollectionTrash(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
+	ep := arvados.EndpointCollectionTrash
+	var resp arvados.Collection
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) CollectionUntrash(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
+	ep := arvados.EndpointCollectionUntrash
+	var resp arvados.Collection
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
 func (conn *Conn) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
 	ep := arvados.EndpointContainerCreate
 	var resp arvados.Container
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index ebf44a822..06439acb5 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -19,6 +19,8 @@ var (
 	EndpointCollectionProvenance          = APIEndpoint{"GET", "arvados/v1/collections/:uuid/provenance", ""}
 	EndpointCollectionUsedBy              = APIEndpoint{"GET", "arvados/v1/collections/:uuid/used_by", ""}
 	EndpointCollectionDelete              = APIEndpoint{"DELETE", "arvados/v1/collections/:uuid", ""}
+	EndpointCollectionTrash               = APIEndpoint{"POST", "arvados/v1/collections/:uuid/trash", ""}
+	EndpointCollectionUntrash             = APIEndpoint{"POST", "arvados/v1/collections/:uuid/untrash", ""}
 	EndpointSpecimenCreate                = APIEndpoint{"POST", "arvados/v1/specimens", "specimen"}
 	EndpointSpecimenUpdate                = APIEndpoint{"PATCH", "arvados/v1/specimens/:uuid", "specimen"}
 	EndpointSpecimenGet                   = APIEndpoint{"GET", "arvados/v1/specimens/:uuid", ""}

commit 1de6535a4c648b27810486c91040b4fe5e9fc93f
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 21 17:18:35 2019 -0400

    14287: Use JSON body for 404 response.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index 61ce66d42..48a336d9e 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -14,6 +14,7 @@ import (
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/auth"
 	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
+	"git.curoverse.com/arvados.git/sdk/go/httpserver"
 	"github.com/julienschmidt/httprouter"
 	"github.com/sirupsen/logrus"
 )
@@ -230,6 +231,9 @@ func (rtr *router) addRoutes(cluster *arvados.Cluster) {
 			})
 		}
 	}
+	rtr.mux.NotFound = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		httpserver.Errors(w, []string{"API endpoint not found"}, http.StatusNotFound)
+	})
 }
 
 func (rtr *router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index f9146b171..d2be9f3f8 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -200,6 +200,25 @@ func (s *RouterSuite) TestSelectParam(c *check.C) {
 	}
 }
 
+func (s *RouterSuite) TestRouteNotFound(c *check.C) {
+	token := arvadostest.ActiveTokenV2
+	req := (&testReq{
+		method: "POST",
+		path:   "arvados/v1/collections/" + arvadostest.FooCollection + "/error404pls",
+		token:  token,
+	}).Request()
+	rr := httptest.NewRecorder()
+	s.rtr.ServeHTTP(rr, req)
+	c.Check(rr.Code, check.Equals, http.StatusNotFound)
+	c.Logf("body: %q", rr.Body.String())
+	var j map[string]interface{}
+	err := json.Unmarshal(rr.Body.Bytes(), &j)
+	c.Check(err, check.IsNil)
+	c.Logf("decoded: %v", j)
+	c.Assert(j["errors"], check.FitsTypeOf, []interface{}{})
+	c.Check(j["errors"].([]interface{})[0], check.Equals, "API endpoint not found")
+}
+
 func (s *RouterSuite) TestCORS(c *check.C) {
 	token := arvadostest.ActiveTokenV2
 	req := (&testReq{

commit 9becfce0f07b082b55e6f5acaa3803eed3722bb7
Merge: 95346e905 1516ef6b9
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 21 13:35:32 2019 -0400

    14287: Merge branch 'master' into 14287-controller-structure
    
    refs #14287
    14287
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>


commit 95346e9057d94ebfe35484e1f9e88fd29d843f49
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 21 11:40:39 2019 -0400

    14287: Add debug logs.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index 7d6f27639..61ce66d42 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -6,6 +6,7 @@ package router
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -14,6 +15,7 @@ import (
 	"git.curoverse.com/arvados.git/sdk/go/auth"
 	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
 	"github.com/julienschmidt/httprouter"
+	"github.com/sirupsen/logrus"
 )
 
 type router struct {
@@ -181,19 +183,23 @@ func (rtr *router) addRoutes(cluster *arvados.Cluster) {
 		}
 		for _, method := range methods {
 			rtr.mux.HandlerFunc(method, "/"+route.endpoint.Path, func(w http.ResponseWriter, req *http.Request) {
+				logger := ctxlog.FromContext(req.Context())
 				params, err := rtr.loadRequestParams(req, route.endpoint.AttrsKey)
 				if err != nil {
+					logger.WithField("req", req).WithField("route", route).WithError(err).Debug("error loading request params")
 					rtr.sendError(w, err)
 					return
 				}
 				opts := route.defaultOpts()
 				err = rtr.transcode(params, opts)
 				if err != nil {
+					logger.WithField("params", params).WithError(err).Debugf("error transcoding params to %T", opts)
 					rtr.sendError(w, err)
 					return
 				}
 				respOpts, err := rtr.responseOptions(opts)
 				if err != nil {
+					logger.WithField("opts", opts).WithError(err).Debugf("error getting response options from %T", opts)
 					rtr.sendError(w, err)
 					return
 				}
@@ -209,9 +215,14 @@ func (rtr *router) addRoutes(cluster *arvados.Cluster) {
 				ctx := req.Context()
 				ctx = context.WithValue(ctx, auth.ContextKeyCredentials, creds)
 				ctx = arvados.ContextWithRequestID(ctx, req.Header.Get("X-Request-Id"))
+				logger.WithFields(logrus.Fields{
+					"apiEndpoint": route.endpoint,
+					"apiOptsType": fmt.Sprintf("%T", opts),
+					"apiOpts":     opts,
+				}).Debug("exec")
 				resp, err := route.exec(ctx, opts)
 				if err != nil {
-					ctxlog.FromContext(ctx).WithError(err).Debugf("returning error response for %#v", err)
+					logger.WithError(err).Debugf("returning error type %T", err)
 					rtr.sendError(w, err)
 					return
 				}

commit d7f03ed68c47e4efccc425010c46e0a6a613e27a
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 21 10:23:09 2019 -0400

    14287: Use ctxlog for httpserver logging.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/federation_test.go b/lib/controller/federation_test.go
index 43344c744..65a6b85c4 100644
--- a/lib/controller/federation_test.go
+++ b/lib/controller/federation_test.go
@@ -6,6 +6,7 @@ package controller
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -72,7 +73,9 @@ func (s *FederationSuite) SetUpTest(c *check.C) {
 		EnableBetaController14287: enableBetaController14287,
 	}, NodeProfile: &nodeProfile}
 	s.testServer = newServerFromIntegrationTestEnv(c)
-	s.testServer.Server.Handler = httpserver.AddRequestIDs(httpserver.LogRequests(s.log, s.testHandler))
+	s.testServer.Server.Handler = httpserver.HandlerWithContext(
+		ctxlog.Context(context.Background(), s.log),
+		httpserver.AddRequestIDs(httpserver.LogRequests(s.testHandler)))
 
 	s.testHandler.Cluster.RemoteClusters = map[string]arvados.RemoteCluster{
 		"zzzzz": {
diff --git a/lib/controller/server_test.go b/lib/controller/server_test.go
index e5fd41712..b591a1795 100644
--- a/lib/controller/server_test.go
+++ b/lib/controller/server_test.go
@@ -5,6 +5,7 @@
 package controller
 
 import (
+	"context"
 	"net/http"
 	"os"
 	"path/filepath"
@@ -47,7 +48,9 @@ func newServerFromIntegrationTestEnv(c *check.C) *httpserver.Server {
 
 	srv := &httpserver.Server{
 		Server: http.Server{
-			Handler: httpserver.AddRequestIDs(httpserver.LogRequests(log, handler)),
+			Handler: httpserver.HandlerWithContext(
+				ctxlog.Context(context.Background(), log),
+				httpserver.AddRequestIDs(httpserver.LogRequests(handler))),
 		},
 		Addr: nodeProfile.Controller.Listen,
 	}
diff --git a/lib/service/cmd.go b/lib/service/cmd.go
index 4b7341d72..6ea22d30b 100644
--- a/lib/service/cmd.go
+++ b/lib/service/cmd.go
@@ -120,7 +120,8 @@ func (c *command) RunCommand(prog string, args []string, stdin io.Reader, stdout
 	}
 	srv := &httpserver.Server{
 		Server: http.Server{
-			Handler: httpserver.AddRequestIDs(httpserver.LogRequests(log, handler)),
+			Handler: httpserver.HandlerWithContext(ctx,
+				httpserver.AddRequestIDs(httpserver.LogRequests(handler))),
 		},
 		Addr: listen,
 	}
diff --git a/sdk/go/httpserver/logger.go b/sdk/go/httpserver/logger.go
index 357daee26..f64708454 100644
--- a/sdk/go/httpserver/logger.go
+++ b/sdk/go/httpserver/logger.go
@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"time"
 
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
 	"git.curoverse.com/arvados.git/sdk/go/stats"
 	"github.com/sirupsen/logrus"
 )
@@ -19,18 +20,23 @@ type contextKey struct {
 
 var (
 	requestTimeContextKey = contextKey{"requestTime"}
-	loggerContextKey      = contextKey{"logger"}
 )
 
+// HandlerWithContext returns an http.Handler that changes the request
+// context to ctx (replacing http.Server's default
+// context.Background()), then calls next.
+func HandlerWithContext(ctx context.Context, next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		next.ServeHTTP(w, r.WithContext(ctx))
+	})
+}
+
 // LogRequests wraps an http.Handler, logging each request and
-// response via logger.
-func LogRequests(logger logrus.FieldLogger, h http.Handler) http.Handler {
-	if logger == nil {
-		logger = logrus.StandardLogger()
-	}
+// response.
+func LogRequests(h http.Handler) http.Handler {
 	return http.HandlerFunc(func(wrapped http.ResponseWriter, req *http.Request) {
 		w := &responseTimer{ResponseWriter: WrapResponseWriter(wrapped)}
-		lgr := logger.WithFields(logrus.Fields{
+		lgr := ctxlog.FromContext(req.Context()).WithFields(logrus.Fields{
 			"RequestID":       req.Header.Get("X-Request-Id"),
 			"remoteAddr":      req.RemoteAddr,
 			"reqForwardedFor": req.Header.Get("X-Forwarded-For"),
@@ -42,7 +48,7 @@ func LogRequests(logger logrus.FieldLogger, h http.Handler) http.Handler {
 		})
 		ctx := req.Context()
 		ctx = context.WithValue(ctx, &requestTimeContextKey, time.Now())
-		ctx = context.WithValue(ctx, &loggerContextKey, lgr)
+		ctx = ctxlog.Context(ctx, lgr)
 		req = req.WithContext(ctx)
 
 		logRequest(w, req, lgr)
@@ -52,11 +58,7 @@ func LogRequests(logger logrus.FieldLogger, h http.Handler) http.Handler {
 }
 
 func Logger(req *http.Request) logrus.FieldLogger {
-	if lgr, ok := req.Context().Value(&loggerContextKey).(logrus.FieldLogger); ok {
-		return lgr
-	} else {
-		return logrus.StandardLogger()
-	}
+	return ctxlog.FromContext(req.Context())
 }
 
 func logRequest(w *responseTimer, req *http.Request, lgr *logrus.Entry) {
diff --git a/sdk/go/httpserver/logger_test.go b/sdk/go/httpserver/logger_test.go
index 8386db927..3b2bc7758 100644
--- a/sdk/go/httpserver/logger_test.go
+++ b/sdk/go/httpserver/logger_test.go
@@ -6,12 +6,14 @@ package httpserver
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 	"time"
 
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
 	"github.com/sirupsen/logrus"
 	check "gopkg.in/check.v1"
 )
@@ -31,15 +33,19 @@ func (s *Suite) TestLogRequests(c *check.C) {
 	log.Formatter = &logrus.JSONFormatter{
 		TimestampFormat: time.RFC3339Nano,
 	}
+	ctx := ctxlog.Context(context.Background(), log)
+
+	h := AddRequestIDs(LogRequests(
+		http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			w.Write([]byte("hello world"))
+		})))
 
-	h := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-		w.Write([]byte("hello world"))
-	})
 	req, err := http.NewRequest("GET", "https://foo.example/bar", nil)
 	req.Header.Set("X-Forwarded-For", "1.2.3.4:12345")
 	c.Assert(err, check.IsNil)
 	resp := httptest.NewRecorder()
-	AddRequestIDs(LogRequests(log, h)).ServeHTTP(resp, req)
+
+	HandlerWithContext(ctx, h).ServeHTTP(resp, req)
 
 	dec := json.NewDecoder(captured)
 
diff --git a/sdk/go/httpserver/metrics.go b/sdk/go/httpserver/metrics.go
index 032093f8d..fab6c3f11 100644
--- a/sdk/go/httpserver/metrics.go
+++ b/sdk/go/httpserver/metrics.go
@@ -104,7 +104,7 @@ func (m *metrics) ServeAPI(token string, next http.Handler) http.Handler {
 //
 // For the metrics to be accurate, the caller must ensure every
 // request passed to the Handler also passes through
-// LogRequests(logger, ...), and vice versa.
+// LogRequests(...), and vice versa.
 //
 // If registry is nil, a new registry is created.
 //
diff --git a/services/keep-balance/server.go b/services/keep-balance/server.go
index 894056c9f..e2f13a425 100644
--- a/services/keep-balance/server.go
+++ b/services/keep-balance/server.go
@@ -5,6 +5,7 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"os"
@@ -14,6 +15,7 @@ import (
 
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/auth"
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
 	"git.curoverse.com/arvados.git/sdk/go/httpserver"
 	"github.com/sirupsen/logrus"
 )
@@ -127,11 +129,13 @@ func (srv *Server) start() error {
 	if srv.config.Listen == "" {
 		return nil
 	}
+	ctx := ctxlog.Context(context.Background(), srv.Logger)
 	server := &httpserver.Server{
 		Server: http.Server{
-			Handler: httpserver.LogRequests(srv.Logger,
-				auth.RequireLiteralToken(srv.config.ManagementToken,
-					srv.metrics.Handler(srv.Logger))),
+			Handler: httpserver.HandlerWithContext(ctx,
+				httpserver.LogRequests(
+					auth.RequireLiteralToken(srv.config.ManagementToken,
+						srv.metrics.Handler(srv.Logger)))),
 		},
 		Addr: srv.config.Listen,
 	}
diff --git a/services/keep-web/server.go b/services/keep-web/server.go
index f70dd1a71..167fbbe5b 100644
--- a/services/keep-web/server.go
+++ b/services/keep-web/server.go
@@ -5,10 +5,13 @@
 package main
 
 import (
+	"context"
 	"net/http"
 
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
 	"git.curoverse.com/arvados.git/sdk/go/httpserver"
 	"github.com/prometheus/client_golang/prometheus"
+	"github.com/sirupsen/logrus"
 )
 
 type server struct {
@@ -20,7 +23,8 @@ func (srv *server) Start() error {
 	h := &handler{Config: srv.Config}
 	reg := prometheus.NewRegistry()
 	h.Config.Cache.registry = reg
-	mh := httpserver.Instrument(reg, nil, httpserver.AddRequestIDs(httpserver.LogRequests(nil, h)))
+	ctx := ctxlog.Context(context.Background(), logrus.StandardLogger())
+	mh := httpserver.Instrument(reg, nil, httpserver.HandlerWithContext(ctx, httpserver.AddRequestIDs(httpserver.LogRequests(h))))
 	h.MetricsAPI = mh.ServeAPI(h.Config.ManagementToken, http.NotFoundHandler())
 	srv.Handler = mh
 	srv.Addr = srv.Config.Listen
diff --git a/services/keepproxy/keepproxy.go b/services/keepproxy/keepproxy.go
index c6fd99b9d..f8aa6c4aa 100644
--- a/services/keepproxy/keepproxy.go
+++ b/services/keepproxy/keepproxy.go
@@ -182,7 +182,7 @@ func main() {
 
 	// Start serving requests.
 	router = MakeRESTRouter(!cfg.DisableGet, !cfg.DisablePut, kc, time.Duration(cfg.Timeout), cfg.ManagementToken)
-	http.Serve(listener, httpserver.AddRequestIDs(httpserver.LogRequests(nil, router)))
+	http.Serve(listener, httpserver.AddRequestIDs(httpserver.LogRequests(router)))
 
 	log.Println("shutting down")
 }
diff --git a/services/keepstore/handlers.go b/services/keepstore/handlers.go
index 9a4d02df8..72088e2b5 100644
--- a/services/keepstore/handlers.go
+++ b/services/keepstore/handlers.go
@@ -21,6 +21,7 @@ import (
 	"time"
 
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
 	"git.curoverse.com/arvados.git/sdk/go/health"
 	"git.curoverse.com/arvados.git/sdk/go/httpserver"
 	"github.com/gorilla/mux"
@@ -93,8 +94,10 @@ func MakeRESTRouter(cluster *arvados.Cluster, reg *prometheus.Registry) http.Han
 	rtr.metrics.setupWorkQueueMetrics(trashq, "trash")
 	rtr.metrics.setupRequestMetrics(rtr.limiter)
 
-	instrumented := httpserver.Instrument(rtr.metrics.reg, nil,
-		httpserver.AddRequestIDs(httpserver.LogRequests(nil, rtr.limiter)))
+	instrumented := httpserver.Instrument(rtr.metrics.reg, log,
+		httpserver.HandlerWithContext(
+			ctxlog.Context(context.Background(), log),
+			httpserver.AddRequestIDs(httpserver.LogRequests(rtr.limiter))))
 	return instrumented.ServeAPI(theConfig.ManagementToken, instrumented)
 }
 

commit 87fdc9b81ce5af793375176b63c237683a55d5f9
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 21 09:38:57 2019 -0400

    14287: Handle CORS OPTIONS requests.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index 3dba53edd..7d6f27639 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -7,6 +7,7 @@ package router
 import (
 	"context"
 	"net/http"
+	"strings"
 
 	"git.curoverse.com/arvados.git/lib/controller/federation"
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
@@ -221,6 +222,17 @@ func (rtr *router) addRoutes(cluster *arvados.Cluster) {
 }
 
 func (rtr *router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	switch strings.SplitN(strings.TrimLeft(r.URL.Path, "/"), "/", 2)[0] {
+	case "login", "logout", "auth":
+	default:
+		w.Header().Set("Access-Control-Allow-Origin", "*")
+		w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, POST, DELETE")
+		w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
+		w.Header().Set("Access-Control-Max-Age", "86486400")
+	}
+	if r.Method == "OPTIONS" {
+		return
+	}
 	r.ParseForm()
 	if m := r.FormValue("_method"); m != "" {
 		r2 := *r
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 348216d18..f9146b171 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -46,21 +46,21 @@ func (s *RouterSuite) doRequest(c *check.C, token, method, path string, hdrs htt
 		req.Header[k] = v
 	}
 	req.Header.Set("Authorization", "Bearer "+token)
-	rw := httptest.NewRecorder()
-	s.rtr.ServeHTTP(rw, req)
-	c.Logf("response body: %s", rw.Body.String())
+	rr := httptest.NewRecorder()
+	s.rtr.ServeHTTP(rr, req)
+	c.Logf("response body: %s", rr.Body.String())
 	var jresp map[string]interface{}
-	err := json.Unmarshal(rw.Body.Bytes(), &jresp)
+	err := json.Unmarshal(rr.Body.Bytes(), &jresp)
 	c.Check(err, check.IsNil)
-	return req, rw, jresp
+	return req, rr, jresp
 }
 
 func (s *RouterSuite) TestCollectionResponses(c *check.C) {
 	token := arvadostest.ActiveTokenV2
 
 	// Check "get collection" response has "kind" key
-	_, rw, jresp := s.doRequest(c, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"include_trash":true}`))
-	c.Check(rw.Code, check.Equals, http.StatusOK)
+	_, rr, jresp := s.doRequest(c, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"include_trash":true}`))
+	c.Check(rr.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
 	c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
 	c.Check(jresp["items"].([]interface{})[0].(map[string]interface{})["kind"], check.Equals, "arvados#collection")
@@ -73,8 +73,8 @@ func (s *RouterSuite) TestCollectionResponses(c *check.C) {
 		`,"select":["name"]`,
 		`,"select":["uuid"]`,
 	} {
-		_, rw, jresp = s.doRequest(c, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"where":{"uuid":["`+arvadostest.FooCollection+`"]}`+selectj+`}`))
-		c.Check(rw.Code, check.Equals, http.StatusOK)
+		_, rr, jresp = s.doRequest(c, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"where":{"uuid":["`+arvadostest.FooCollection+`"]}`+selectj+`}`))
+		c.Check(rr.Code, check.Equals, http.StatusOK)
 		c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
 		c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
 		c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
@@ -98,8 +98,8 @@ func (s *RouterSuite) TestCollectionResponses(c *check.C) {
 	}
 
 	// Check "create collection" response has "kind" key
-	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections`, http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, bytes.NewBufferString(`ensure_unique_name=true`))
-	c.Check(rw.Code, check.Equals, http.StatusOK)
+	_, rr, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections`, http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, bytes.NewBufferString(`ensure_unique_name=true`))
+	c.Check(rr.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["uuid"], check.FitsTypeOf, "")
 	c.Check(jresp["kind"], check.Equals, "arvados#collection")
 }
@@ -107,14 +107,14 @@ func (s *RouterSuite) TestCollectionResponses(c *check.C) {
 func (s *RouterSuite) TestContainerList(c *check.C) {
 	token := arvadostest.ActiveTokenV2
 
-	_, rw, jresp := s.doRequest(c, token, "GET", `/arvados/v1/containers?limit=0`, nil, nil)
-	c.Check(rw.Code, check.Equals, http.StatusOK)
+	_, rr, jresp := s.doRequest(c, token, "GET", `/arvados/v1/containers?limit=0`, nil, nil)
+	c.Check(rr.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
 	c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
 	c.Check(jresp["items"], check.HasLen, 0)
 
-	_, rw, jresp = s.doRequest(c, token, "GET", `/arvados/v1/containers?limit=2&select=["uuid","command"]`, nil, nil)
-	c.Check(rw.Code, check.Equals, http.StatusOK)
+	_, rr, jresp = s.doRequest(c, token, "GET", `/arvados/v1/containers?limit=2&select=["uuid","command"]`, nil, nil)
+	c.Check(rr.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
 	c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
 	c.Check(jresp["items"], check.HasLen, 2)
@@ -124,8 +124,8 @@ func (s *RouterSuite) TestContainerList(c *check.C) {
 	c.Check(item0["command"].([]interface{})[0], check.FitsTypeOf, "")
 	c.Check(item0["mounts"], check.IsNil)
 
-	_, rw, jresp = s.doRequest(c, token, "GET", `/arvados/v1/containers`, nil, nil)
-	c.Check(rw.Code, check.Equals, http.StatusOK)
+	_, rr, jresp = s.doRequest(c, token, "GET", `/arvados/v1/containers`, nil, nil)
+	c.Check(rr.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
 	c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
 	avail := int(jresp["items_available"].(float64))
@@ -140,20 +140,20 @@ func (s *RouterSuite) TestContainerList(c *check.C) {
 func (s *RouterSuite) TestContainerLock(c *check.C) {
 	uuid := arvadostest.QueuedContainerUUID
 	token := arvadostest.AdminToken
-	_, rw, jresp := s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
-	c.Check(rw.Code, check.Equals, http.StatusOK)
+	_, rr, jresp := s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
+	c.Check(rr.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["uuid"], check.HasLen, 27)
 	c.Check(jresp["state"], check.Equals, "Locked")
-	_, rw, jresp = s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
-	c.Check(rw.Code, check.Equals, http.StatusUnprocessableEntity)
-	c.Check(rw.Body.String(), check.Not(check.Matches), `.*"uuid":.*`)
-	_, rw, jresp = s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
-	c.Check(rw.Code, check.Equals, http.StatusOK)
+	_, rr, jresp = s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
+	c.Check(rr.Code, check.Equals, http.StatusUnprocessableEntity)
+	c.Check(rr.Body.String(), check.Not(check.Matches), `.*"uuid":.*`)
+	_, rr, jresp = s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
+	c.Check(rr.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["uuid"], check.HasLen, 27)
 	c.Check(jresp["state"], check.Equals, "Queued")
 	c.Check(jresp["environment"], check.IsNil)
-	_, rw, jresp = s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
-	c.Check(rw.Code, check.Equals, http.StatusUnprocessableEntity)
+	_, rr, jresp = s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
+	c.Check(rr.Code, check.Equals, http.StatusUnprocessableEntity)
 	c.Check(jresp["uuid"], check.IsNil)
 }
 
@@ -161,8 +161,8 @@ func (s *RouterSuite) TestFullTimestampsInResponse(c *check.C) {
 	uuid := arvadostest.CollectionReplicationDesired2Confirmed2UUID
 	token := arvadostest.ActiveTokenV2
 
-	_, rw, jresp := s.doRequest(c, token, "GET", `/arvados/v1/collections/`+uuid, nil, nil)
-	c.Check(rw.Code, check.Equals, http.StatusOK)
+	_, rr, jresp := s.doRequest(c, token, "GET", `/arvados/v1/collections/`+uuid, nil, nil)
+	c.Check(rr.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["uuid"], check.Equals, uuid)
 	expectNS := map[string]int{
 		"created_at":  596506000, // fixture says 596506247, but truncated by postgresql
@@ -188,8 +188,8 @@ func (s *RouterSuite) TestSelectParam(c *check.C) {
 	} {
 		j, err := json.Marshal(sel)
 		c.Assert(err, check.IsNil)
-		_, rw, resp := s.doRequest(c, token, "GET", "/arvados/v1/containers/"+uuid+"?select="+string(j), nil, nil)
-		c.Check(rw.Code, check.Equals, http.StatusOK)
+		_, rr, resp := s.doRequest(c, token, "GET", "/arvados/v1/containers/"+uuid+"?select="+string(j), nil, nil)
+		c.Check(rr.Code, check.Equals, http.StatusOK)
 
 		c.Check(resp["kind"], check.Equals, "arvados#container")
 		c.Check(resp["uuid"], check.HasLen, 27)
@@ -199,3 +199,52 @@ func (s *RouterSuite) TestSelectParam(c *check.C) {
 		c.Check(hasMounts, check.Equals, false)
 	}
 }
+
+func (s *RouterSuite) TestCORS(c *check.C) {
+	token := arvadostest.ActiveTokenV2
+	req := (&testReq{
+		method: "OPTIONS",
+		path:   "arvados/v1/collections/" + arvadostest.FooCollection,
+		header: http.Header{"Origin": {"https://example.com"}},
+		token:  token,
+	}).Request()
+	rr := httptest.NewRecorder()
+	s.rtr.ServeHTTP(rr, req)
+	c.Check(rr.Code, check.Equals, http.StatusOK)
+	c.Check(rr.Body.String(), check.HasLen, 0)
+	c.Check(rr.Result().Header.Get("Access-Control-Allow-Origin"), check.Equals, "*")
+	for _, hdr := range []string{"Authorization", "Content-Type"} {
+		c.Check(rr.Result().Header.Get("Access-Control-Allow-Headers"), check.Matches, ".*"+hdr+".*")
+	}
+	for _, method := range []string{"GET", "HEAD", "PUT", "POST", "DELETE"} {
+		c.Check(rr.Result().Header.Get("Access-Control-Allow-Methods"), check.Matches, ".*"+method+".*")
+	}
+
+	for _, unsafe := range []string{"login", "logout", "auth", "auth/foo", "login/?blah"} {
+		req := (&testReq{
+			method: "OPTIONS",
+			path:   unsafe,
+			header: http.Header{"Origin": {"https://example.com"}},
+			token:  token,
+		}).Request()
+		rr := httptest.NewRecorder()
+		s.rtr.ServeHTTP(rr, req)
+		c.Check(rr.Code, check.Equals, http.StatusOK)
+		c.Check(rr.Body.String(), check.HasLen, 0)
+		c.Check(rr.Result().Header.Get("Access-Control-Allow-Origin"), check.Equals, "")
+		c.Check(rr.Result().Header.Get("Access-Control-Allow-Methods"), check.Equals, "")
+		c.Check(rr.Result().Header.Get("Access-Control-Allow-Headers"), check.Equals, "")
+
+		req = (&testReq{
+			method: "POST",
+			path:   unsafe,
+			header: http.Header{"Origin": {"https://example.com"}},
+			token:  token,
+		}).Request()
+		rr = httptest.NewRecorder()
+		s.rtr.ServeHTTP(rr, req)
+		c.Check(rr.Result().Header.Get("Access-Control-Allow-Origin"), check.Equals, "")
+		c.Check(rr.Result().Header.Get("Access-Control-Allow-Methods"), check.Equals, "")
+		c.Check(rr.Result().Header.Get("Access-Control-Allow-Headers"), check.Equals, "")
+	}
+}

commit cea4c0f2b9d80cd22355014090c68a8db227cf04
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon May 20 16:04:02 2019 -0400

    14287: Enable readline history in run-tests.sh interactive mode.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/build/run-tests.sh b/build/run-tests.sh
index 9a7b87646..a81f37ef7 100755
--- a/build/run-tests.sh
+++ b/build/run-tests.sh
@@ -1218,19 +1218,21 @@ else
             # assume emacs, or something, is offering a history buffer
             # and pre-populating the command will only cause trouble
             nextcmd=
-        elif [[ "$nextcmd" != "install deps" ]]; then
-            :
-        elif [[ -e "$VENVDIR/bin/activate" ]]; then
-            nextcmd="test lib/cmd"
-        else
+        elif [[ ! -e "$VENVDIR/bin/activate" ]]; then
             nextcmd="install deps"
+        else
+            nextcmd=""
         fi
     }
     echo
     help_interactive
     nextcmd="install deps"
     setnextcmd
-    while read -p 'What next? ' -e -i "${nextcmd}" nextcmd; do
+    HISTFILE="$WORKSPACE/tmp/.history"
+    history -r
+    while read -p 'What next? ' -e -i "$nextcmd" nextcmd; do
+        history -s "$nextcmd"
+        history -w
         read verb target opts <<<"${nextcmd}"
         target="${target%/}"
         target="${target/\/:/:}"

commit 40711540d5f82594dda6a476ecdf8f1fecf1a214
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon May 20 09:46:00 2019 -0400

    f UpdateBody
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/go/arvados/fs_backend.go b/sdk/go/arvados/fs_backend.go
index 9ae0fc3a5..c8308aea5 100644
--- a/sdk/go/arvados/fs_backend.go
+++ b/sdk/go/arvados/fs_backend.go
@@ -26,5 +26,4 @@ type keepClient interface {
 
 type apiClient interface {
 	RequestAndDecode(dst interface{}, method, path string, body io.Reader, params interface{}) error
-	UpdateBody(rsc resource) io.Reader
 }

commit 274aa34f6098df959ded86272567f01e3a35eb6d
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Fri May 17 15:31:28 2019 -0400

    14287: Ignore etag and unsigned_manifest_text in collection updates.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/request.go b/lib/controller/router/request.go
index de288a0a2..47d8bb110 100644
--- a/lib/controller/router/request.go
+++ b/lib/controller/router/request.go
@@ -97,6 +97,13 @@ func (rtr *router) loadRequestParams(req *http.Request, attrsKey string) (map[st
 	}
 
 	if v, ok := params[attrsKey]; ok && attrsKey != "" {
+		if v, ok := v.(map[string]interface{}); ok {
+			// Delete field(s) that appear in responses
+			// but not in update attrs, so clients can
+			// fetch-modify-update.
+			delete(v, "etag")
+			delete(v, "unsigned_manifest_text")
+		}
 		params["attrs"] = v
 		delete(params, attrsKey)
 	}

commit d99c3d34dd752789c11538a7a13b9e41dc9254e1
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Fri May 17 15:30:20 2019 -0400

    14287: Use map instead of UpdateBody to update specific attrs.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/go/arvados/fs_collection.go b/sdk/go/arvados/fs_collection.go
index 6644f4cfb..972b3979f 100644
--- a/sdk/go/arvados/fs_collection.go
+++ b/sdk/go/arvados/fs_collection.go
@@ -131,7 +131,12 @@ func (fs *collectionFileSystem) Sync() error {
 		UUID:         fs.uuid,
 		ManifestText: txt,
 	}
-	err = fs.RequestAndDecode(nil, "PUT", "arvados/v1/collections/"+fs.uuid, fs.UpdateBody(coll), map[string]interface{}{"select": []string{"uuid"}})
+	err = fs.RequestAndDecode(nil, "PUT", "arvados/v1/collections/"+fs.uuid, nil, map[string]interface{}{
+		"collection": map[string]string{
+			"manifest_text": coll.ManifestText,
+		},
+		"select": []string{"uuid"},
+	})
 	if err != nil {
 		return fmt.Errorf("sync failed: update %s: %s", fs.uuid, err)
 	}
diff --git a/sdk/go/arvados/fs_project_test.go b/sdk/go/arvados/fs_project_test.go
index 1a06ce146..0b8ce90b3 100644
--- a/sdk/go/arvados/fs_project_test.go
+++ b/sdk/go/arvados/fs_project_test.go
@@ -119,20 +119,24 @@ func (s *SiteFSSuite) TestProjectReaddirAfterLoadOne(c *check.C) {
 }
 
 func (s *SiteFSSuite) TestSlashInName(c *check.C) {
-	badCollection := Collection{
-		Name:      "bad/collection",
-		OwnerUUID: arvadostest.AProjectUUID,
-	}
-	err := s.client.RequestAndDecode(&badCollection, "POST", "arvados/v1/collections", s.client.UpdateBody(&badCollection), nil)
+	var badCollection Collection
+	err := s.client.RequestAndDecode(&badCollection, "POST", "arvados/v1/collections", nil, map[string]interface{}{
+		"collection": map[string]string{
+			"name":       "bad/collection",
+			"owner_uuid": arvadostest.AProjectUUID,
+		},
+	})
 	c.Assert(err, check.IsNil)
 	defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/collections/"+badCollection.UUID, nil, nil)
 
-	badProject := Group{
-		Name:       "bad/project",
-		GroupClass: "project",
-		OwnerUUID:  arvadostest.AProjectUUID,
-	}
-	err = s.client.RequestAndDecode(&badProject, "POST", "arvados/v1/groups", s.client.UpdateBody(&badProject), nil)
+	var badProject Group
+	err = s.client.RequestAndDecode(&badProject, "POST", "arvados/v1/groups", nil, map[string]interface{}{
+		"group": map[string]string{
+			"name":        "bad/project",
+			"group_class": "project",
+			"owner_uuid":  arvadostest.AProjectUUID,
+		},
+	})
 	c.Assert(err, check.IsNil)
 	defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/groups/"+badProject.UUID, nil, nil)
 
@@ -155,11 +159,13 @@ func (s *SiteFSSuite) TestProjectUpdatedByOther(c *check.C) {
 	_, err = s.fs.Open("/home/A Project/oob")
 	c.Check(err, check.NotNil)
 
-	oob := Collection{
-		Name:      "oob",
-		OwnerUUID: arvadostest.AProjectUUID,
-	}
-	err = s.client.RequestAndDecode(&oob, "POST", "arvados/v1/collections", s.client.UpdateBody(&oob), nil)
+	var oob Collection
+	err = s.client.RequestAndDecode(&oob, "POST", "arvados/v1/collections", nil, map[string]interface{}{
+		"collection": map[string]string{
+			"name":       "oob",
+			"owner_uuid": arvadostest.AProjectUUID,
+		},
+	})
 	c.Assert(err, check.IsNil)
 	defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/collections/"+oob.UUID, nil, nil)
 
@@ -180,8 +186,13 @@ func (s *SiteFSSuite) TestProjectUpdatedByOther(c *check.C) {
 	c.Check(err, check.IsNil)
 
 	// Delete test.txt behind s.fs's back by updating the
-	// collection record with the old (empty) ManifestText.
-	err = s.client.RequestAndDecode(nil, "PATCH", "arvados/v1/collections/"+oob.UUID, s.client.UpdateBody(&oob), nil)
+	// collection record with an empty ManifestText.
+	err = s.client.RequestAndDecode(nil, "PATCH", "arvados/v1/collections/"+oob.UUID, nil, map[string]interface{}{
+		"collection": map[string]string{
+			"manifest_text":      "",
+			"portable_data_hash": "d41d8cd98f00b204e9800998ecf8427e+0",
+		},
+	})
 	c.Assert(err, check.IsNil)
 
 	err = project.Sync()
diff --git a/services/keep-web/cache.go b/services/keep-web/cache.go
index 8336b78f9..b9a1f3069 100644
--- a/services/keep-web/cache.go
+++ b/services/keep-web/cache.go
@@ -157,7 +157,11 @@ func (c *cache) Update(client *arvados.Client, coll arvados.Collection, fs arvad
 	}
 	var updated arvados.Collection
 	defer c.pdhs.Remove(coll.UUID)
-	err := client.RequestAndDecode(&updated, "PATCH", "arvados/v1/collections/"+coll.UUID, client.UpdateBody(coll), nil)
+	err := client.RequestAndDecode(&updated, "PATCH", "arvados/v1/collections/"+coll.UUID, nil, map[string]interface{}{
+		"collection": map[string]string{
+			"manifest_text": coll.ManifestText,
+		},
+	})
 	if err == nil {
 		c.collections.Add(client.AuthToken+"\000"+coll.PortableDataHash, &cachedCollection{
 			expire:     time.Now().Add(time.Duration(c.TTL)),
diff --git a/services/keep-web/cadaver_test.go b/services/keep-web/cadaver_test.go
index 44d0b0ffe..bf7a1942b 100644
--- a/services/keep-web/cadaver_test.go
+++ b/services/keep-web/cadaver_test.go
@@ -9,7 +9,6 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
-	"net/url"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -74,7 +73,7 @@ func (s *IntegrationSuite) testCadaver(c *check.C, password string, pathFunc fun
 	var newCollection arvados.Collection
 	arv := arvados.NewClientFromEnv()
 	arv.AuthToken = arvadostest.ActiveToken
-	err = arv.RequestAndDecode(&newCollection, "POST", "arvados/v1/collections", bytes.NewBufferString(url.Values{"collection": {"{}"}}.Encode()), nil)
+	err = arv.RequestAndDecode(&newCollection, "POST", "arvados/v1/collections", nil, map[string]interface{}{"collection": map[string]interface{}{}})
 	c.Assert(err, check.IsNil)
 
 	readPath, writePath, pdhPath := pathFunc(newCollection)
diff --git a/services/keep-web/handler_test.go b/services/keep-web/handler_test.go
index 7a015c91f..95535249b 100644
--- a/services/keep-web/handler_test.go
+++ b/services/keep-web/handler_test.go
@@ -465,8 +465,12 @@ func (s *IntegrationSuite) TestSpecialCharsInPath(c *check.C) {
 	f.Close()
 	mtxt, err := fs.MarshalManifest(".")
 	c.Assert(err, check.IsNil)
-	coll := arvados.Collection{ManifestText: mtxt}
-	err = client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", client.UpdateBody(coll), nil)
+	var coll arvados.Collection
+	err = client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, map[string]interface{}{
+		"collection": map[string]string{
+			"manifest_text": mtxt,
+		},
+	})
 	c.Assert(err, check.IsNil)
 
 	u, _ := url.Parse("http://download.example.com/c=" + coll.UUID + "/")
@@ -773,11 +777,14 @@ func (s *IntegrationSuite) TestDirectoryListing(c *check.C) {
 func (s *IntegrationSuite) TestDeleteLastFile(c *check.C) {
 	arv := arvados.NewClientFromEnv()
 	var newCollection arvados.Collection
-	err := arv.RequestAndDecode(&newCollection, "POST", "arvados/v1/collections", arv.UpdateBody(&arvados.Collection{
-		OwnerUUID:    arvadostest.ActiveUserUUID,
-		ManifestText: ". acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:foo.txt 0:3:bar.txt\n",
-		Name:         "keep-web test collection",
-	}), map[string]bool{"ensure_unique_name": true})
+	err := arv.RequestAndDecode(&newCollection, "POST", "arvados/v1/collections", nil, map[string]interface{}{
+		"collection": map[string]string{
+			"owner_uuid":    arvadostest.ActiveUserUUID,
+			"manifest_text": ". acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:foo.txt 0:3:bar.txt\n",
+			"name":          "keep-web test collection",
+		},
+		"ensure_unique_name": true,
+	})
 	c.Assert(err, check.IsNil)
 	defer arv.RequestAndDecode(&newCollection, "DELETE", "arvados/v1/collections/"+newCollection.UUID, nil, nil)
 

commit 9ee7fdab1e90acb63e1941d5bde04615491e006e
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Fri May 17 15:28:31 2019 -0400

    14287: Include x-request-id in test suite nginx access log.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/python/tests/nginx.conf b/sdk/python/tests/nginx.conf
index 1ef3b00c6..a7b8bacdc 100644
--- a/sdk/python/tests/nginx.conf
+++ b/sdk/python/tests/nginx.conf
@@ -8,7 +8,7 @@ events {
 }
 http {
   log_format customlog
-    '[$time_local] $server_name $status $body_bytes_sent $request_time $request_method "$scheme://$http_host$request_uri" $remote_addr:$remote_port '
+    '[$time_local] "$http_x_request_id" $server_name $status $body_bytes_sent $request_time $request_method "$scheme://$http_host$request_uri" $remote_addr:$remote_port '
     '"$http_referer" "$http_user_agent"';
   access_log "{{ACCESSLOG}}" customlog;
   client_body_temp_path "{{TMPDIR}}";

commit b98681c27aa33df769fc933dd0eeadd6bc208173
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu May 16 16:04:30 2019 -0400

    14287: Propagate include_trash flag.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 00d93367a..ebf44a822 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -40,14 +40,16 @@ type GetOptions struct {
 }
 
 type ListOptions struct {
-	Select   []string               `json:"select"`
-	Filters  []Filter               `json:"filters"`
-	Where    map[string]interface{} `json:"where"`
-	Limit    int                    `json:"limit"`
-	Offset   int                    `json:"offset"`
-	Order    []string               `json:"order"`
-	Distinct bool                   `json:"distinct"`
-	Count    string                 `json:"count"`
+	Select             []string               `json:"select"`
+	Filters            []Filter               `json:"filters"`
+	Where              map[string]interface{} `json:"where"`
+	Limit              int                    `json:"limit"`
+	Offset             int                    `json:"offset"`
+	Order              []string               `json:"order"`
+	Distinct           bool                   `json:"distinct"`
+	Count              string                 `json:"count"`
+	IncludeTrash       bool                   `json:"include_trash"`
+	IncludeOldVersions bool                   `json:"include_old_versions"`
 }
 
 type CreateOptions struct {

commit 4238b017e7332a700e644015889a4fcdf251595a
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu May 16 14:09:48 2019 -0400

    14287: Propagate "distinct" param.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/request.go b/lib/controller/router/request.go
index f9eb3e76d..de288a0a2 100644
--- a/lib/controller/router/request.go
+++ b/lib/controller/router/request.go
@@ -134,6 +134,7 @@ func (rtr *router) transcode(src interface{}, dst interface{}) error {
 }
 
 var boolParams = map[string]bool{
+	"distinct":             true,
 	"ensure_unique_name":   true,
 	"include_trash":        true,
 	"include_old_versions": true,
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 597f47a95..00d93367a 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -40,13 +40,14 @@ type GetOptions struct {
 }
 
 type ListOptions struct {
-	Select  []string               `json:"select"`
-	Filters []Filter               `json:"filters"`
-	Where   map[string]interface{} `json:"where"`
-	Limit   int                    `json:"limit"`
-	Offset  int                    `json:"offset"`
-	Order   []string               `json:"order"`
-	Count   string                 `json:"count"`
+	Select   []string               `json:"select"`
+	Filters  []Filter               `json:"filters"`
+	Where    map[string]interface{} `json:"where"`
+	Limit    int                    `json:"limit"`
+	Offset   int                    `json:"offset"`
+	Order    []string               `json:"order"`
+	Distinct bool                   `json:"distinct"`
+	Count    string                 `json:"count"`
 }
 
 type CreateOptions struct {

commit 96bb342903bc651483f64bc7e5cd769b2ad49077
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu May 16 14:09:40 2019 -0400

    14287: Don't send reader_tokens="[false]".
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/apps/workbench/app/models/arvados_api_client.rb b/apps/workbench/app/models/arvados_api_client.rb
index 5a8fd518d..ce91cd305 100644
--- a/apps/workbench/app/models/arvados_api_client.rb
+++ b/apps/workbench/app/models/arvados_api_client.rb
@@ -113,11 +113,13 @@ class ArvadosApiClient
     # Clean up /arvados/v1/../../discovery/v1 to /discovery/v1
     url.sub! '/arvados/v1/../../', '/'
 
+    anon_tokens = [Rails.configuration.anonymous_user_token].select { |x| x && include_anon_token }
+
     query = {
       'reader_tokens' => ((tokens[:reader_tokens] ||
                            Thread.current[:reader_tokens] ||
                            []) +
-                          (include_anon_token ? [Rails.configuration.anonymous_user_token] : [])).to_json,
+                          anon_tokens).to_json,
     }
     if !data.nil?
       data.each do |k,v|

commit 486286dd282fe2c95701229d0872855bfd99c3bf
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu May 16 11:30:47 2019 -0400

    14287: Change test fixture so PDH is really not readable by active.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/services/api/test/fixtures/collections.yml b/services/api/test/fixtures/collections.yml
index 47a0950ae..1503f6bc0 100644
--- a/services/api/test/fixtures/collections.yml
+++ b/services/api/test/fixtures/collections.yml
@@ -801,14 +801,14 @@ collection_with_several_unsupported_file_types:
 collection_not_readable_by_active:
   uuid: zzzzz-4zz18-cd42uwvy3neko21
   current_version_uuid: zzzzz-4zz18-cd42uwvy3neko21
-  portable_data_hash: fa7aeb5140e2848d39b416daeef4ffc5+45
+  portable_data_hash: b9e51a238ce08a698e7d7f8f101aee18+55
   owner_uuid: zzzzz-tpzed-000000000000000
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
   modified_by_user_uuid: zzzzz-tpzed-d9tiejq69daie8f
   modified_at: 2014-02-03T17:22:54Z
   updated_at: 2014-02-03T17:22:54Z
-  manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+  manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar 0:0:empty\n"
   name: collection_not_readable_by_active
 
 collection_to_remove_and_rename_files:

commit 67c59cd7bd3906f559b9a571821b426fe10dfcb2
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu May 16 10:55:35 2019 -0400

    14287: Accept order param as string or array.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/request.go b/lib/controller/router/request.go
index aa2cd636c..f9eb3e76d 100644
--- a/lib/controller/router/request.go
+++ b/lib/controller/router/request.go
@@ -100,6 +100,19 @@ func (rtr *router) loadRequestParams(req *http.Request, attrsKey string) (map[st
 		params["attrs"] = v
 		delete(params, attrsKey)
 	}
+
+	if order, ok := params["order"].(string); ok {
+		// We must accept strings ("foo, bar desc") and arrays
+		// (["foo", "bar desc"]) because RailsAPI does.
+		// Convert to an array here before trying to unmarshal
+		// into options structs.
+		if order == "" {
+			delete(params, "order")
+		} else {
+			params["order"] = strings.Split(order, ",")
+		}
+	}
+
 	return params, nil
 }
 
diff --git a/lib/controller/router/request_test.go b/lib/controller/router/request_test.go
index 02cc9ce3f..89238f656 100644
--- a/lib/controller/router/request_test.go
+++ b/lib/controller/router/request_test.go
@@ -166,3 +166,41 @@ func (s *RouterSuite) TestBoolParam(c *check.C) {
 		c.Check(params[testKey], check.Equals, true)
 	}
 }
+
+func (s *RouterSuite) TestOrderParam(c *check.C) {
+	for i, tr := range []testReq{
+		{method: "POST", param: map[string]interface{}{"order": ""}, json: true},
+		{method: "POST", param: map[string]interface{}{"order": ""}, json: false},
+		{method: "POST", param: map[string]interface{}{"order": []string{}}, json: true},
+		{method: "POST", param: map[string]interface{}{"order": []string{}}, json: false},
+		{method: "POST", param: map[string]interface{}{}, json: true},
+		{method: "POST", param: map[string]interface{}{}, json: false},
+	} {
+		c.Logf("#%d, tr: %#v", i, tr)
+		req := tr.Request()
+		params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+		c.Assert(err, check.IsNil)
+		c.Assert(params, check.NotNil)
+		if order, ok := params["order"]; ok && order != nil {
+			c.Check(order, check.DeepEquals, []interface{}{})
+		}
+	}
+
+	for i, tr := range []testReq{
+		{method: "POST", param: map[string]interface{}{"order": "foo,bar desc"}, json: true},
+		{method: "POST", param: map[string]interface{}{"order": "foo,bar desc"}, json: false},
+		{method: "POST", param: map[string]interface{}{"order": "[\"foo\", \"bar desc\"]"}, json: false},
+		{method: "POST", param: map[string]interface{}{"order": []string{"foo", "bar desc"}}, json: true},
+		{method: "POST", param: map[string]interface{}{"order": []string{"foo", "bar desc"}}, json: false},
+	} {
+		c.Logf("#%d, tr: %#v", i, tr)
+		req := tr.Request()
+		params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+		c.Assert(err, check.IsNil)
+		if _, ok := params["order"].([]string); ok {
+			c.Check(params["order"], check.DeepEquals, []string{"foo", "bar desc"})
+		} else {
+			c.Check(params["order"], check.DeepEquals, []interface{}{"foo", "bar desc"})
+		}
+	}
+}
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 874e9e517..597f47a95 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -45,7 +45,7 @@ type ListOptions struct {
 	Where   map[string]interface{} `json:"where"`
 	Limit   int                    `json:"limit"`
 	Offset  int                    `json:"offset"`
-	Order   string                 `json:"order"`
+	Order   []string               `json:"order"`
 	Count   string                 `json:"count"`
 }
 

commit a69691cd906202ad4a4d61021f79c443f76c3327
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu May 16 09:56:53 2019 -0400

    14287: Propagate reader_tokens to Rails API.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index 179436034..3dba53edd 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -198,6 +198,13 @@ func (rtr *router) addRoutes(cluster *arvados.Cluster) {
 				}
 
 				creds := auth.CredentialsFromRequest(req)
+				if rt, _ := params["reader_tokens"].([]interface{}); len(rt) > 0 {
+					for _, t := range rt {
+						if t, ok := t.(string); ok {
+							creds.Tokens = append(creds.Tokens, t)
+						}
+					}
+				}
 				ctx := req.Context()
 				ctx = context.WithValue(ctx, auth.ContextKeyCredentials, creds)
 				ctx = arvados.ContextWithRequestID(ctx, req.Header.Get("X-Request-Id"))
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
index e74e870ad..4533bfa1e 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -103,6 +103,9 @@ func (conn *Conn) requestAndDecode(ctx context.Context, dst interface{}, ep arva
 		// remove it entirely.
 		delete(params, "limit")
 	}
+	if len(tokens) > 1 {
+		params["reader_tokens"] = tokens[1:]
+	}
 	path := ep.Path
 	if strings.Contains(ep.Path, "/:uuid") {
 		uuid, _ := params["uuid"].(string)

commit e99fc3e3e4a996e5a6ead330a60f639b5c9fbd29
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Wed May 15 14:41:23 2019 -0400

    14287: Fix test fixture PDH.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/apps/workbench/test/controllers/application_controller_test.rb b/apps/workbench/test/controllers/application_controller_test.rb
index 45952ceba..6870adb34 100644
--- a/apps/workbench/test/controllers/application_controller_test.rb
+++ b/apps/workbench/test/controllers/application_controller_test.rb
@@ -325,9 +325,9 @@ class ApplicationControllerTest < ActionController::TestCase
     # Each pdh has more than one collection; however, we should get only one for each
     assert collections.size == 2, 'Expected two objects in the preloaded collection hash'
     assert collections[pdh1], 'Expected collections for the passed in pdh #{pdh1}'
-    assert_equal collections[pdh1].size, 1, 'Expected one collection for the passed in pdh #{pdh1}'
+    assert_equal collections[pdh1].size, 1, "Expected one collection for the passed in pdh #{pdh1}"
     assert collections[pdh2], 'Expected collections for the passed in pdh #{pdh2}'
-    assert_equal collections[pdh2].size, 1, 'Expected one collection for the passed in pdh #{pdh2}'
+    assert_equal collections[pdh2].size, 1, "Expected one collection for the passed in pdh #{pdh2}"
   end
 
   test "requesting a nonexistent object returns 404" do
diff --git a/services/api/test/fixtures/collections.yml b/services/api/test/fixtures/collections.yml
index 5024ecc96..47a0950ae 100644
--- a/services/api/test/fixtures/collections.yml
+++ b/services/api/test/fixtures/collections.yml
@@ -101,7 +101,7 @@ baz_file:
 w_a_z_file:
   uuid: zzzzz-4zz18-25k12570yk134b3
   current_version_uuid: zzzzz-4zz18-25k12570yk134b3
-  portable_data_hash: 8706aadd12a0ebc07d74cae88762ba9e+56
+  portable_data_hash: 44a8da9ec82098323895cd14e178386f+56
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2015-02-09T10:53:38Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr

commit 8d60b124aba24724a0ace90872276e1f1c5a09f3
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Wed May 15 12:04:19 2019 -0400

    14287: Fill in resp.items[].kind even if no uuid/pdh is selected.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/response.go b/lib/controller/router/response.go
index 3eba61145..995fb01ff 100644
--- a/lib/controller/router/response.go
+++ b/lib/controller/router/response.go
@@ -65,18 +65,28 @@ func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts re
 	if respKind != "" {
 		tmp["kind"] = respKind
 	}
+	defaultItemKind := ""
+	if strings.HasSuffix(respKind, "List") {
+		defaultItemKind = strings.TrimSuffix(respKind, "List")
+	}
 
 	if items, ok := tmp["items"].([]interface{}); ok {
 		for i, item := range items {
-			// Fill in "kind" by inspecting UUID
+			// Fill in "kind" by inspecting UUID/PDH if
+			// possible; fall back on assuming each
+			// Items[] entry in an "arvados#fooList"
+			// response should have kind="arvados#foo".
 			item, _ := item.(map[string]interface{})
-			uuid, _ := item["uuid"].(string)
-			if len(uuid) != 27 {
-				// unsure whether this happens
-			} else if t, ok := infixMap[uuid[6:11]]; !ok {
-				// infix not listed in infixMap
-			} else if k := kind(t); k != "" {
+			infix := ""
+			if uuid, _ := item["uuid"].(string); len(uuid) == 27 {
+				infix = uuid[6:11]
+			}
+			if k := kind(infixMap[infix]); k != "" {
 				item["kind"] = k
+			} else if pdh, _ := item["portable_data_hash"].(string); pdh != "" {
+				item["kind"] = "arvados#collection"
+			} else if defaultItemKind != "" {
+				item["kind"] = defaultItemKind
 			}
 			items[i] = applySelectParam(opts.Select, item)
 		}
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 2e354f925..348216d18 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -11,6 +11,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"os"
+	"strings"
 	"testing"
 	"time"
 
@@ -54,27 +55,50 @@ func (s *RouterSuite) doRequest(c *check.C, token, method, path string, hdrs htt
 	return req, rw, jresp
 }
 
-func (s *RouterSuite) TestCollectionParams(c *check.C) {
+func (s *RouterSuite) TestCollectionResponses(c *check.C) {
 	token := arvadostest.ActiveTokenV2
 
-	_, rw, jresp := s.doRequest(c, token, "GET", `/arvados/v1/collections?include_trash=true`, nil, nil)
-	c.Check(rw.Code, check.Equals, http.StatusOK)
-	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
-	c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
-	c.Check(jresp["items"].([]interface{})[0].(map[string]interface{})["kind"], check.Equals, "arvados#collection")
-
-	_, rw, jresp = s.doRequest(c, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"include_trash":true}`))
+	// Check "get collection" response has "kind" key
+	_, rw, jresp := s.doRequest(c, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"include_trash":true}`))
 	c.Check(rw.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
 	c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
 	c.Check(jresp["items"].([]interface{})[0].(map[string]interface{})["kind"], check.Equals, "arvados#collection")
 
-	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections`, http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, bytes.NewBufferString(`ensure_unique_name=true`))
-	c.Check(rw.Code, check.Equals, http.StatusOK)
-	c.Check(jresp["uuid"], check.FitsTypeOf, "")
-	c.Check(jresp["kind"], check.Equals, "arvados#collection")
+	// Check items in list response have a "kind" key regardless
+	// of whether a uuid/pdh is selected.
+	for _, selectj := range []string{
+		``,
+		`,"select":["portable_data_hash"]`,
+		`,"select":["name"]`,
+		`,"select":["uuid"]`,
+	} {
+		_, rw, jresp = s.doRequest(c, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"where":{"uuid":["`+arvadostest.FooCollection+`"]}`+selectj+`}`))
+		c.Check(rw.Code, check.Equals, http.StatusOK)
+		c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
+		c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+		c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
+		item0 := jresp["items"].([]interface{})[0].(map[string]interface{})
+		c.Check(item0["kind"], check.Equals, "arvados#collection")
+		if selectj == "" || strings.Contains(selectj, "portable_data_hash") {
+			c.Check(item0["portable_data_hash"], check.Equals, arvadostest.FooCollectionPDH)
+		} else {
+			c.Check(item0["portable_data_hash"], check.IsNil)
+		}
+		if selectj == "" || strings.Contains(selectj, "name") {
+			c.Check(item0["name"], check.FitsTypeOf, "")
+		} else {
+			c.Check(item0["name"], check.IsNil)
+		}
+		if selectj == "" || strings.Contains(selectj, "uuid") {
+			c.Check(item0["uuid"], check.Equals, arvadostest.FooCollection)
+		} else {
+			c.Check(item0["uuid"], check.IsNil)
+		}
+	}
 
-	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections?ensure_unique_name=true`, nil, nil)
+	// Check "create collection" response has "kind" key
+	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections`, http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, bytes.NewBufferString(`ensure_unique_name=true`))
 	c.Check(rw.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["uuid"], check.FitsTypeOf, "")
 	c.Check(jresp["kind"], check.Equals, "arvados#collection")

commit d74b9938429acae9f2459d81baa987ef22d75e3a
Merge: 900e78f4d d235817fe
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Wed May 15 11:12:30 2019 -0400

    14287: Merge branch 'master' into 14287-controller-structure
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --cc lib/controller/federation_test.go
index 06c8f0086,c4aa33c15..43344c744
--- a/lib/controller/federation_test.go
+++ b/lib/controller/federation_test.go
@@@ -65,11 -64,10 +65,11 @@@ func (s *FederationSuite) SetUpTest(c *
  		NodeProfiles: map[string]arvados.NodeProfile{
  			"*": nodeProfile,
  		},
- 		RequestLimits: arvados.RequestLimits{
- 			MaxItemsPerResponse:            1000,
- 			MultiClusterRequestConcurrency: 4,
+ 		API: arvados.API{
+ 			MaxItemsPerResponse:     1000,
+ 			MaxRequestAmplification: 4,
  		},
 +		EnableBetaController14287: enableBetaController14287,
  	}, NodeProfile: &nodeProfile}
  	s.testServer = newServerFromIntegrationTestEnv(c)
  	s.testServer.Server.Handler = httpserver.AddRequestIDs(httpserver.LogRequests(s.log, s.testHandler))
diff --cc lib/controller/handler.go
index c799b617f,775d29034..8df6ab4e5
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@@ -73,14 -72,8 +73,15 @@@ func (h *Handler) setup() 
  	mux.Handle("/_health/", &health.Handler{
  		Token:  h.Cluster.ManagementToken,
  		Prefix: "/_health/",
+ 		Routes: health.Routes{"ping": func() error { _, err := h.db(&http.Request{}); return err }},
  	})
 +
 +	if h.Cluster.EnableBetaController14287 {
 +		rtr := router.New(h.Cluster, h.NodeProfile)
 +		mux.Handle("/arvados/v1/collections", rtr)
 +		mux.Handle("/arvados/v1/collections/", rtr)
 +	}
 +
  	hs := http.NotFoundHandler()
  	hs = prepend(hs, h.proxyRailsAPI)
  	hs = h.setupProxyRemoteCluster(hs)
diff --cc sdk/go/arvados/config.go
index d309748f4,6b3150c6f..6d249b1df
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@@ -68,11 -68,9 +68,11 @@@ type Cluster struct 
  	HTTPRequestTimeout Duration
  	RemoteClusters     map[string]RemoteCluster
  	PostgreSQL         PostgreSQL
- 	RequestLimits      RequestLimits
+ 	API                API
  	Logging            Logging
  	TLS                TLS
 +
 +	EnableBetaController14287 bool
  }
  
  type Services struct {
diff --cc sdk/python/tests/run_test_server.py
index d52a28459,79767c2fa..e595a298a
--- a/sdk/python/tests/run_test_server.py
+++ b/sdk/python/tests/run_test_server.py
@@@ -413,8 -413,7 +413,9 @@@ def run_controller()
          f.write("""
  Clusters:
    zzzzz:
 +    Logging:
 +      Level: "{}"
+     ManagementToken: e687950a23c3a9bceec28c6223a06c79
      HTTPRequestTimeout: 30s
      PostgreSQL:
        ConnectionPool: 32

commit 900e78f4d16da54796797db543b9777292ab5ec6
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 14 11:20:49 2019 -0400

    14287: Handle collection/.../provenance and .../used_by requests.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
index ad46d8788..cdebde885 100644
--- a/lib/controller/federation/conn.go
+++ b/lib/controller/federation/conn.go
@@ -26,6 +26,8 @@ type Interface interface {
 	CollectionUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Collection, error)
 	CollectionGet(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error)
 	CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error)
+	CollectionProvenance(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error)
+	CollectionUsedBy(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error)
 	CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error)
 	ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error)
 	ContainerUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Container, error)
@@ -240,6 +242,14 @@ func (conn *Conn) CollectionList(ctx context.Context, options arvados.ListOption
 	return conn.local.CollectionList(ctx, options)
 }
 
+func (conn *Conn) CollectionProvenance(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
+	return conn.local.CollectionProvenance(ctx, options)
+}
+
+func (conn *Conn) CollectionUsedBy(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
+	return conn.local.CollectionUsedBy(ctx, options)
+}
+
 func (conn *Conn) CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
 	return conn.chooseBackend(options.UUID).CollectionDelete(ctx, options)
 }
diff --git a/lib/controller/router/response.go b/lib/controller/router/response.go
index 82ca5ef5e..3eba61145 100644
--- a/lib/controller/router/response.go
+++ b/lib/controller/router/response.go
@@ -53,15 +53,19 @@ func applySelectParam(selectParam []string, orig map[string]interface{}) map[str
 }
 
 func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts responseOptions) {
-	respKind := kind(resp)
 	var tmp map[string]interface{}
+
 	err := rtr.transcode(resp, &tmp)
 	if err != nil {
 		rtr.sendError(w, err)
 		return
 	}
 
-	tmp["kind"] = respKind
+	respKind := kind(resp)
+	if respKind != "" {
+		tmp["kind"] = respKind
+	}
+
 	if items, ok := tmp["items"].([]interface{}); ok {
 		for i, item := range items {
 			// Fill in "kind" by inspecting UUID
@@ -71,8 +75,8 @@ func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts re
 				// unsure whether this happens
 			} else if t, ok := infixMap[uuid[6:11]]; !ok {
 				// infix not listed in infixMap
-			} else {
-				item["kind"] = kind(t)
+			} else if k := kind(t); k != "" {
+				item["kind"] = k
 			}
 			items[i] = applySelectParam(opts.Select, item)
 		}
@@ -125,7 +129,11 @@ var infixMap = map[string]interface{}{
 var mungeKind = regexp.MustCompile(`\..`)
 
 func kind(resp interface{}) string {
-	return mungeKind.ReplaceAllStringFunc(fmt.Sprintf("%T", resp), func(s string) string {
+	t := fmt.Sprintf("%T", resp)
+	if !strings.HasPrefix(t, "arvados.") {
+		return ""
+	}
+	return mungeKind.ReplaceAllStringFunc(t, func(s string) string {
 		// "arvados.CollectionList" => "arvados#collectionList"
 		return "#" + strings.ToLower(s[1:])
 	})
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index cd66e90d2..179436034 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -64,6 +64,20 @@ func (rtr *router) addRoutes(cluster *arvados.Cluster) {
 			},
 		},
 		{
+			arvados.EndpointCollectionProvenance,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.CollectionProvenance(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointCollectionUsedBy,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.CollectionUsedBy(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
 			arvados.EndpointCollectionDelete,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
index 9bb3eb33f..e74e870ad 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -140,6 +140,20 @@ func (conn *Conn) CollectionList(ctx context.Context, options arvados.ListOption
 	return resp, err
 }
 
+func (conn *Conn) CollectionProvenance(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
+	ep := arvados.EndpointCollectionProvenance
+	var resp map[string]interface{}
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) CollectionUsedBy(ctx context.Context, options arvados.GetOptions) (map[string]interface{}, error) {
+	ep := arvados.EndpointCollectionUsedBy
+	var resp map[string]interface{}
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
 func (conn *Conn) CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
 	ep := arvados.EndpointCollectionDelete
 	var resp arvados.Collection
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 84f73c5a2..874e9e517 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -16,6 +16,8 @@ var (
 	EndpointCollectionUpdate              = APIEndpoint{"PATCH", "arvados/v1/collections/:uuid", "collection"}
 	EndpointCollectionGet                 = APIEndpoint{"GET", "arvados/v1/collections/:uuid", ""}
 	EndpointCollectionList                = APIEndpoint{"GET", "arvados/v1/collections", ""}
+	EndpointCollectionProvenance          = APIEndpoint{"GET", "arvados/v1/collections/:uuid/provenance", ""}
+	EndpointCollectionUsedBy              = APIEndpoint{"GET", "arvados/v1/collections/:uuid/used_by", ""}
 	EndpointCollectionDelete              = APIEndpoint{"DELETE", "arvados/v1/collections/:uuid", ""}
 	EndpointSpecimenCreate                = APIEndpoint{"POST", "arvados/v1/specimens", "specimen"}
 	EndpointSpecimenUpdate                = APIEndpoint{"PATCH", "arvados/v1/specimens/:uuid", "specimen"}

commit 4490616be768aeda32979995cedcb6c7ca79504e
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 14 11:20:10 2019 -0400

    14287: Test request formatting variations.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/request_test.go b/lib/controller/router/request_test.go
index cffdccc90..02cc9ce3f 100644
--- a/lib/controller/router/request_test.go
+++ b/lib/controller/router/request_test.go
@@ -6,24 +6,163 @@ package router
 
 import (
 	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
 	"net/http/httptest"
+	"net/url"
 
+	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
 	check "gopkg.in/check.v1"
 )
 
+type testReq struct {
+	method   string
+	path     string
+	token    string // default is ActiveTokenV2; use noToken to omit
+	param    map[string]interface{}
+	attrs    map[string]interface{}
+	attrsKey string
+	header   http.Header
+
+	// variations on request formatting
+	json            bool
+	jsonAttrsTop    bool
+	jsonStringParam bool
+	tokenInBody     bool
+	tokenInQuery    bool
+	noContentType   bool
+
+	body *bytes.Buffer
+}
+
+const noToken = "(no token)"
+
+func (tr *testReq) Request() *http.Request {
+	param := map[string]interface{}{}
+	for k, v := range tr.param {
+		param[k] = v
+	}
+
+	if tr.body != nil {
+		// caller provided a buffer
+	} else if tr.json {
+		if tr.jsonAttrsTop {
+			for k, v := range tr.attrs {
+				param[k] = v
+			}
+		} else if tr.attrs != nil {
+			param[tr.attrsKey] = tr.attrs
+		}
+		tr.body = bytes.NewBuffer(nil)
+		err := json.NewEncoder(tr.body).Encode(param)
+		if err != nil {
+			panic(err)
+		}
+	} else {
+		values := make(url.Values)
+		for k, v := range param {
+			if vs, ok := v.(string); ok && !tr.jsonStringParam {
+				values.Set(k, vs)
+			} else {
+				jv, err := json.Marshal(v)
+				if err != nil {
+					panic(err)
+				}
+				values.Set(k, string(jv))
+			}
+		}
+		if tr.attrs != nil {
+			jattrs, err := json.Marshal(tr.attrs)
+			if err != nil {
+				panic(err)
+			}
+			values.Set(tr.attrsKey, string(jattrs))
+		}
+		tr.body = bytes.NewBuffer(nil)
+		io.WriteString(tr.body, values.Encode())
+	}
+	method := tr.method
+	if method == "" {
+		method = "GET"
+	}
+	path := tr.path
+	if path == "" {
+		path = "example/test/path"
+	}
+	req := httptest.NewRequest(method, "https://an.example/"+path, tr.body)
+	token := tr.token
+	if token == "" {
+		token = arvadostest.ActiveTokenV2
+	}
+	if token != noToken {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+	if tr.json {
+		req.Header.Set("Content-Type", "application/json")
+	} else {
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	}
+	for k, v := range tr.header {
+		req.Header[k] = append([]string(nil), v...)
+	}
+	return req
+}
+
+func (tr *testReq) bodyContent() string {
+	return string(tr.body.Bytes())
+}
+
 func (s *RouterSuite) TestAttrsInBody(c *check.C) {
-	for _, body := range []string{
-		`{"foo":"bar"}`,
-		`{"model_name": {"foo":"bar"}}`,
+	attrs := map[string]interface{}{"foo": "bar"}
+	for _, tr := range []testReq{
+		{attrsKey: "model_name", json: true, attrs: attrs},
+		{attrsKey: "model_name", json: true, attrs: attrs, jsonAttrsTop: true},
 	} {
-		c.Logf("body: %s", body)
-		req := httptest.NewRequest("POST", "https://an.example/ctrl", bytes.NewBufferString(body))
-		req.Header.Set("Content-Type", "application/json")
-		params, err := s.rtr.loadRequestParams(req, "model_name")
-		c.Assert(err, check.IsNil)
+		c.Logf("tr: %#v", tr)
+		req := tr.Request()
+		params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
 		c.Logf("params: %#v", params)
+		c.Assert(err, check.IsNil)
 		c.Check(params, check.NotNil)
 		c.Assert(params["attrs"], check.FitsTypeOf, map[string]interface{}{})
 		c.Check(params["attrs"].(map[string]interface{})["foo"], check.Equals, "bar")
 	}
 }
+
+func (s *RouterSuite) TestBoolParam(c *check.C) {
+	testKey := "ensure_unique_name"
+
+	for i, tr := range []testReq{
+		{method: "POST", param: map[string]interface{}{testKey: false}, json: true},
+		{method: "POST", param: map[string]interface{}{testKey: false}},
+		{method: "POST", param: map[string]interface{}{testKey: "false"}},
+		{method: "POST", param: map[string]interface{}{testKey: "0"}},
+		{method: "POST", param: map[string]interface{}{testKey: ""}},
+	} {
+		c.Logf("#%d, tr: %#v", i, tr)
+		req := tr.Request()
+		c.Logf("tr.body: %s", tr.bodyContent())
+		params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+		c.Logf("params: %#v", params)
+		c.Assert(err, check.IsNil)
+		c.Check(params, check.NotNil)
+		c.Check(params[testKey], check.Equals, false)
+	}
+
+	for i, tr := range []testReq{
+		{method: "POST", param: map[string]interface{}{testKey: true}, json: true},
+		{method: "POST", param: map[string]interface{}{testKey: true}},
+		{method: "POST", param: map[string]interface{}{testKey: "true"}},
+		{method: "POST", param: map[string]interface{}{testKey: "1"}},
+	} {
+		c.Logf("#%d, tr: %#v", i, tr)
+		req := tr.Request()
+		c.Logf("tr.body: %s", tr.bodyContent())
+		params, err := s.rtr.loadRequestParams(req, tr.attrsKey)
+		c.Logf("params: %#v", params)
+		c.Assert(err, check.IsNil)
+		c.Check(params, check.NotNil)
+		c.Check(params[testKey], check.Equals, true)
+	}
+}

commit 7520945351c2bb42481354c57e66486f271734fc
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon May 13 15:58:43 2019 -0400

    14287: Set controller log level=debug in tests if ARVADOS_DEBUG set.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/python/tests/run_test_server.py b/sdk/python/tests/run_test_server.py
index 6687ca491..d52a28459 100644
--- a/sdk/python/tests/run_test_server.py
+++ b/sdk/python/tests/run_test_server.py
@@ -413,6 +413,8 @@ def run_controller():
         f.write("""
 Clusters:
   zzzzz:
+    Logging:
+      Level: "{}"
     HTTPRequestTimeout: 30s
     PostgreSQL:
       ConnectionPool: 32
@@ -430,6 +432,7 @@ Clusters:
           TLS: true
           Insecure: true
         """.format(
+            ('info' if os.environ.get('ARVADOS_DEBUG', '') in ['','0'] else 'debug'),
             _dbconfig('host'),
             _dbconfig('database'),
             _dbconfig('username'),

commit cce14cb6e78ee472cfde4629101e284b2cb63d1b
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon May 13 15:58:01 2019 -0400

    14287: Propagate etag in collection records in responses.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/go/arvados/collection.go b/sdk/go/arvados/collection.go
index 136159a7e..5b919bea7 100644
--- a/sdk/go/arvados/collection.go
+++ b/sdk/go/arvados/collection.go
@@ -16,6 +16,7 @@ import (
 // Collection is an arvados#collection resource.
 type Collection struct {
 	UUID                      string                 `json:"uuid"`
+	Etag                      string                 `json:"etag"`
 	OwnerUUID                 string                 `json:"owner_uuid"`
 	TrashAt                   *time.Time             `json:"trash_at"`
 	ManifestText              string                 `json:"manifest_text"`

commit 5fa8839f730408828a583f1e80c2538b23fa7005
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon May 13 15:57:36 2019 -0400

    14287: Propagate where param in list requests.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index d53907308..84f73c5a2 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -38,12 +38,13 @@ type GetOptions struct {
 }
 
 type ListOptions struct {
-	Select  []string `json:"select"`
-	Filters []Filter `json:"filters"`
-	Limit   int      `json:"limit"`
-	Offset  int      `json:"offset"`
-	Order   string   `json:"order"`
-	Count   string   `json:"count"`
+	Select  []string               `json:"select"`
+	Filters []Filter               `json:"filters"`
+	Where   map[string]interface{} `json:"where"`
+	Limit   int                    `json:"limit"`
+	Offset  int                    `json:"offset"`
+	Order   string                 `json:"order"`
+	Count   string                 `json:"count"`
 }
 
 type CreateOptions struct {

commit 57abc6f7661db3dda9eee9ca66669f642d1cb4bd
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon May 13 15:55:54 2019 -0400

    14287: Remove extra zeroes from items[] entries too.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/response.go b/lib/controller/router/response.go
index 4536380fd..82ca5ef5e 100644
--- a/lib/controller/router/response.go
+++ b/lib/controller/router/response.go
@@ -35,22 +35,54 @@ func (rtr *router) responseOptions(opts interface{}) (responseOptions, error) {
 	return rOpts, nil
 }
 
+func applySelectParam(selectParam []string, orig map[string]interface{}) map[string]interface{} {
+	if len(selectParam) == 0 {
+		return orig
+	}
+	selected := map[string]interface{}{}
+	for _, attr := range selectParam {
+		if v, ok := orig[attr]; ok {
+			selected[attr] = v
+		}
+	}
+	// Preserve "kind" even if not requested
+	if v, ok := orig["kind"]; ok {
+		selected["kind"] = v
+	}
+	return selected
+}
+
 func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts responseOptions) {
+	respKind := kind(resp)
 	var tmp map[string]interface{}
 	err := rtr.transcode(resp, &tmp)
 	if err != nil {
 		rtr.sendError(w, err)
 		return
 	}
-	if len(opts.Select) > 0 {
-		selected := map[string]interface{}{}
-		for _, attr := range opts.Select {
-			if v, ok := tmp[attr]; ok {
-				selected[attr] = v
+
+	tmp["kind"] = respKind
+	if items, ok := tmp["items"].([]interface{}); ok {
+		for i, item := range items {
+			// Fill in "kind" by inspecting UUID
+			item, _ := item.(map[string]interface{})
+			uuid, _ := item["uuid"].(string)
+			if len(uuid) != 27 {
+				// unsure whether this happens
+			} else if t, ok := infixMap[uuid[6:11]]; !ok {
+				// infix not listed in infixMap
+			} else {
+				item["kind"] = kind(t)
 			}
+			items[i] = applySelectParam(opts.Select, item)
+		}
+		if opts.Count == "none" {
+			delete(tmp, "items_available")
 		}
-		tmp = selected
+	} else {
+		tmp = applySelectParam(opts.Select, tmp)
 	}
+
 	// Format non-nil timestamps as rfc3339NanoFixed (by default
 	// they will have been encoded to time.RFC3339Nano, which
 	// omits trailing zeroes).
@@ -74,7 +106,6 @@ func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts re
 			tmp[k] = t.Format(rfc3339NanoFixed)
 		}
 	}
-	tmp["kind"] = kind(resp)
 	json.NewEncoder(w).Encode(tmp)
 }
 
@@ -86,6 +117,11 @@ func (rtr *router) sendError(w http.ResponseWriter, err error) {
 	httpserver.Error(w, err.Error(), code)
 }
 
+var infixMap = map[string]interface{}{
+	"4zz18": arvados.Collection{},
+	"j7d0g": arvados.Group{},
+}
+
 var mungeKind = regexp.MustCompile(`\..`)
 
 func kind(resp interface{}) string {

commit 20c5632277f5f87ea047ba51dca82f0ce8aed81d
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon May 13 15:51:51 2019 -0400

    14287: Remove zero/missing values when req uses select or count=none.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/response.go b/lib/controller/router/response.go
index 9a2891140..4536380fd 100644
--- a/lib/controller/router/response.go
+++ b/lib/controller/router/response.go
@@ -20,6 +20,7 @@ const rfc3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
 
 type responseOptions struct {
 	Select []string
+	Count  string
 }
 
 func (rtr *router) responseOptions(opts interface{}) (responseOptions, error) {
@@ -27,6 +28,9 @@ func (rtr *router) responseOptions(opts interface{}) (responseOptions, error) {
 	switch opts := opts.(type) {
 	case *arvados.GetOptions:
 		rOpts.Select = opts.Select
+	case *arvados.ListOptions:
+		rOpts.Select = opts.Select
+		rOpts.Count = opts.Count
 	}
 	return rOpts, nil
 }
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index a1c790680..d53907308 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -43,6 +43,7 @@ type ListOptions struct {
 	Limit   int      `json:"limit"`
 	Offset  int      `json:"offset"`
 	Order   string   `json:"order"`
+	Count   string   `json:"count"`
 }
 
 type CreateOptions struct {

commit 9a251d966ae83934381840e4b47478e10221d265
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Fri May 10 16:00:37 2019 -0400

    14287: Add "kind" key to controller responses.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/response.go b/lib/controller/router/response.go
index ddbeee666..9a2891140 100644
--- a/lib/controller/router/response.go
+++ b/lib/controller/router/response.go
@@ -6,7 +6,9 @@ package router
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
+	"regexp"
 	"strings"
 	"time"
 
@@ -68,6 +70,7 @@ func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts re
 			tmp[k] = t.Format(rfc3339NanoFixed)
 		}
 	}
+	tmp["kind"] = kind(resp)
 	json.NewEncoder(w).Encode(tmp)
 }
 
@@ -78,3 +81,12 @@ func (rtr *router) sendError(w http.ResponseWriter, err error) {
 	}
 	httpserver.Error(w, err.Error(), code)
 }
+
+var mungeKind = regexp.MustCompile(`\..`)
+
+func kind(resp interface{}) string {
+	return mungeKind.ReplaceAllStringFunc(fmt.Sprintf("%T", resp), func(s string) string {
+		// "arvados.CollectionList" => "arvados#collectionList"
+		return "#" + strings.ToLower(s[1:])
+	})
+}
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 686b8933a..2e354f925 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -60,18 +60,24 @@ func (s *RouterSuite) TestCollectionParams(c *check.C) {
 	_, rw, jresp := s.doRequest(c, token, "GET", `/arvados/v1/collections?include_trash=true`, nil, nil)
 	c.Check(rw.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+	c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
+	c.Check(jresp["items"].([]interface{})[0].(map[string]interface{})["kind"], check.Equals, "arvados#collection")
 
 	_, rw, jresp = s.doRequest(c, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"include_trash":true}`))
 	c.Check(rw.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
+	c.Check(jresp["kind"], check.Equals, "arvados#collectionList")
+	c.Check(jresp["items"].([]interface{})[0].(map[string]interface{})["kind"], check.Equals, "arvados#collection")
 
 	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections`, http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, bytes.NewBufferString(`ensure_unique_name=true`))
 	c.Check(rw.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["uuid"], check.FitsTypeOf, "")
+	c.Check(jresp["kind"], check.Equals, "arvados#collection")
 
 	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections?ensure_unique_name=true`, nil, nil)
 	c.Check(rw.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["uuid"], check.FitsTypeOf, "")
+	c.Check(jresp["kind"], check.Equals, "arvados#collection")
 }
 
 func (s *RouterSuite) TestContainerList(c *check.C) {
@@ -161,6 +167,7 @@ func (s *RouterSuite) TestSelectParam(c *check.C) {
 		_, rw, resp := s.doRequest(c, token, "GET", "/arvados/v1/containers/"+uuid+"?select="+string(j), nil, nil)
 		c.Check(rw.Code, check.Equals, http.StatusOK)
 
+		c.Check(resp["kind"], check.Equals, "arvados#container")
 		c.Check(resp["uuid"], check.HasLen, 27)
 		c.Check(resp["command"], check.HasLen, 2)
 		c.Check(resp["mounts"], check.IsNil)

commit 1f2f20ac03b2f11a0bdaa1e057f85e170e1994ed
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu May 9 15:29:47 2019 -0400

    14287: Avoid converting integer params to float and back.
    
    Turns out int64(float64(MaxInt64)) was MinInt64, which is quite
    different.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/go/arvados/client.go b/sdk/go/arvados/client.go
index 8625e7ade..e55cb82f2 100644
--- a/sdk/go/arvados/client.go
+++ b/sdk/go/arvados/client.go
@@ -13,7 +13,6 @@ import (
 	"io"
 	"io/ioutil"
 	"log"
-	"math"
 	"net/http"
 	"net/url"
 	"os"
@@ -188,7 +187,9 @@ func anythingToValues(params interface{}) (url.Values, error) {
 		return nil, err
 	}
 	var generic map[string]interface{}
-	err = json.Unmarshal(j, &generic)
+	dec := json.NewDecoder(bytes.NewBuffer(j))
+	dec.UseNumber()
+	err = dec.Decode(&generic)
 	if err != nil {
 		return nil, err
 	}
@@ -198,22 +199,16 @@ func anythingToValues(params interface{}) (url.Values, error) {
 			urlValues.Set(k, v)
 			continue
 		}
-		if v, ok := v.(float64); ok {
-			// Unmarshal decodes all numbers as float64,
-			// which can be written as 1.2345e4 in JSON,
-			// but this form is not accepted for ints in
-			// url params. If a number fits in an int64,
-			// encode it as int64 rather than float64.
-			if v, frac := math.Modf(v); frac == 0 && v <= math.MaxInt64 && v >= math.MinInt64 {
-				urlValues.Set(k, fmt.Sprintf("%d", int64(v)))
-				continue
-			}
+		if v, ok := v.(json.Number); ok {
+			urlValues.Set(k, v.String())
+			continue
 		}
 		j, err := json.Marshal(v)
 		if err != nil {
 			return nil, err
 		}
-		if string(j) == "null" {
+		if bytes.Equal(j, []byte("null")) {
+			// don't add it to urlValues at all
 			continue
 		}
 		urlValues.Set(k, string(j))

commit 36ceeb15a98e9ce88dea8d262c09d318f57baae8
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu May 9 13:04:56 2019 -0400

    14287: Fix debug log level.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index 4a6f9b5af..cd66e90d2 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -189,7 +189,7 @@ func (rtr *router) addRoutes(cluster *arvados.Cluster) {
 				ctx = arvados.ContextWithRequestID(ctx, req.Header.Get("X-Request-Id"))
 				resp, err := route.exec(ctx, opts)
 				if err != nil {
-					ctxlog.FromContext(ctx).WithError(err).Infof("returning error response for %#v", err)
+					ctxlog.FromContext(ctx).WithError(err).Debugf("returning error response for %#v", err)
 					rtr.sendError(w, err)
 					return
 				}

commit e0f272bd704c44432bab1bcc243fda19caa40f7b
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Wed May 8 11:45:22 2019 -0400

    14287: Remove omitempty tags so zeroes/nulls appear in responses.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/go/arvados/api_client_authorization.go b/sdk/go/arvados/api_client_authorization.go
index 17cff235d..7c17cdef0 100644
--- a/sdk/go/arvados/api_client_authorization.go
+++ b/sdk/go/arvados/api_client_authorization.go
@@ -6,10 +6,10 @@ package arvados
 
 // APIClientAuthorization is an arvados#apiClientAuthorization resource.
 type APIClientAuthorization struct {
-	UUID      string   `json:"uuid,omitempty"`
-	APIToken  string   `json:"api_token,omitempty"`
-	ExpiresAt string   `json:"expires_at,omitempty"`
-	Scopes    []string `json:"scopes,omitempty"`
+	UUID      string   `json:"uuid"`
+	APIToken  string   `json:"api_token"`
+	ExpiresAt string   `json:"expires_at"`
+	Scopes    []string `json:"scopes"`
 }
 
 // APIClientAuthorizationList is an arvados#apiClientAuthorizationList resource.
diff --git a/sdk/go/arvados/collection.go b/sdk/go/arvados/collection.go
index f374eea07..136159a7e 100644
--- a/sdk/go/arvados/collection.go
+++ b/sdk/go/arvados/collection.go
@@ -15,23 +15,24 @@ import (
 
 // Collection is an arvados#collection resource.
 type Collection struct {
-	UUID                      string     `json:"uuid,omitempty"`
-	OwnerUUID                 string     `json:"owner_uuid,omitempty"`
-	TrashAt                   *time.Time `json:"trash_at,omitempty"`
-	ManifestText              string     `json:"manifest_text"`
-	UnsignedManifestText      string     `json:"unsigned_manifest_text,omitempty"`
-	Name                      string     `json:"name,omitempty"`
-	CreatedAt                 *time.Time `json:"created_at,omitempty"`
-	ModifiedAt                *time.Time `json:"modified_at,omitempty"`
-	PortableDataHash          string     `json:"portable_data_hash,omitempty"`
-	ReplicationConfirmed      *int       `json:"replication_confirmed,omitempty"`
-	ReplicationConfirmedAt    *time.Time `json:"replication_confirmed_at,omitempty"`
-	ReplicationDesired        *int       `json:"replication_desired,omitempty"`
-	StorageClassesDesired     []string   `json:"storage_classes_desired,omitempty"`
-	StorageClassesConfirmed   []string   `json:"storage_classes_confirmed,omitempty"`
-	StorageClassesConfirmedAt *time.Time `json:"storage_classes_confirmed_at,omitempty"`
-	DeleteAt                  *time.Time `json:"delete_at,omitempty"`
-	IsTrashed                 bool       `json:"is_trashed,omitempty"`
+	UUID                      string                 `json:"uuid"`
+	OwnerUUID                 string                 `json:"owner_uuid"`
+	TrashAt                   *time.Time             `json:"trash_at"`
+	ManifestText              string                 `json:"manifest_text"`
+	UnsignedManifestText      string                 `json:"unsigned_manifest_text"`
+	Name                      string                 `json:"name"`
+	CreatedAt                 *time.Time             `json:"created_at"`
+	ModifiedAt                *time.Time             `json:"modified_at"`
+	PortableDataHash          string                 `json:"portable_data_hash"`
+	ReplicationConfirmed      *int                   `json:"replication_confirmed"`
+	ReplicationConfirmedAt    *time.Time             `json:"replication_confirmed_at"`
+	ReplicationDesired        *int                   `json:"replication_desired"`
+	StorageClassesDesired     []string               `json:"storage_classes_desired"`
+	StorageClassesConfirmed   []string               `json:"storage_classes_confirmed"`
+	StorageClassesConfirmedAt *time.Time             `json:"storage_classes_confirmed_at"`
+	DeleteAt                  *time.Time             `json:"delete_at"`
+	IsTrashed                 bool                   `json:"is_trashed"`
+	Properties                map[string]interface{} `json:"properties"`
 }
 
 func (c Collection) resourceName() string {
diff --git a/sdk/go/arvados/group.go b/sdk/go/arvados/group.go
index 6b5718a6c..bf2fe72ff 100644
--- a/sdk/go/arvados/group.go
+++ b/sdk/go/arvados/group.go
@@ -6,9 +6,9 @@ package arvados
 
 // Group is an arvados#group record
 type Group struct {
-	UUID       string `json:"uuid,omitempty"`
-	Name       string `json:"name,omitempty"`
-	OwnerUUID  string `json:"owner_uuid,omitempty"`
+	UUID       string `json:"uuid"`
+	Name       string `json:"name"`
+	OwnerUUID  string `json:"owner_uuid"`
 	GroupClass string `json:"group_class"`
 }
 
diff --git a/sdk/go/arvados/link.go b/sdk/go/arvados/link.go
index dee13556e..fbd699f30 100644
--- a/sdk/go/arvados/link.go
+++ b/sdk/go/arvados/link.go
@@ -7,13 +7,13 @@ package arvados
 // Link is an arvados#link record
 type Link struct {
 	UUID      string `json:"uuid,omiempty"`
-	OwnerUUID string `json:"owner_uuid,omitempty"`
-	Name      string `json:"name,omitempty"`
-	LinkClass string `json:"link_class,omitempty"`
-	HeadUUID  string `json:"head_uuid,omitempty"`
-	HeadKind  string `json:"head_kind,omitempty"`
-	TailUUID  string `json:"tail_uuid,omitempty"`
-	TailKind  string `json:"tail_kind,omitempty"`
+	OwnerUUID string `json:"owner_uuid"`
+	Name      string `json:"name"`
+	LinkClass string `json:"link_class"`
+	HeadUUID  string `json:"head_uuid"`
+	HeadKind  string `json:"head_kind"`
+	TailUUID  string `json:"tail_uuid"`
+	TailKind  string `json:"tail_kind"`
 }
 
 // UserList is an arvados#userList resource.
diff --git a/sdk/go/arvados/log.go b/sdk/go/arvados/log.go
index 6f72bf7c6..6f72634e5 100644
--- a/sdk/go/arvados/log.go
+++ b/sdk/go/arvados/log.go
@@ -10,14 +10,14 @@ import (
 
 // Log is an arvados#log record
 type Log struct {
-	ID              uint64                 `json:"id,omitempty"`
-	UUID            string                 `json:"uuid,omitempty"`
-	ObjectUUID      string                 `json:"object_uuid,omitempty"`
-	ObjectOwnerUUID string                 `json:"object_owner_uuid,omitempty"`
-	EventType       string                 `json:"event_type,omitempty"`
-	EventAt         *time.Time             `json:"event,omitempty"`
-	Properties      map[string]interface{} `json:"properties,omitempty"`
-	CreatedAt       *time.Time             `json:"created_at,omitempty"`
+	ID              uint64                 `json:"id"`
+	UUID            string                 `json:"uuid"`
+	ObjectUUID      string                 `json:"object_uuid"`
+	ObjectOwnerUUID string                 `json:"object_owner_uuid"`
+	EventType       string                 `json:"event_type"`
+	EventAt         *time.Time             `json:"event"`
+	Properties      map[string]interface{} `json:"properties"`
+	CreatedAt       *time.Time             `json:"created_at"`
 }
 
 // LogList is an arvados#logList resource.
diff --git a/sdk/go/arvados/node.go b/sdk/go/arvados/node.go
index cc844fe82..97466eb8a 100644
--- a/sdk/go/arvados/node.go
+++ b/sdk/go/arvados/node.go
@@ -12,10 +12,10 @@ type Node struct {
 	Domain     string         `json:"domain"`
 	Hostname   string         `json:"hostname"`
 	IPAddress  string         `json:"ip_address"`
-	LastPingAt *time.Time     `json:"last_ping_at,omitempty"`
+	LastPingAt *time.Time     `json:"last_ping_at"`
 	SlotNumber int            `json:"slot_number"`
 	Status     string         `json:"status"`
-	JobUUID    string         `json:"job_uuid,omitempty"`
+	JobUUID    string         `json:"job_uuid"`
 	Properties NodeProperties `json:"properties"`
 }
 
diff --git a/sdk/go/arvados/user.go b/sdk/go/arvados/user.go
index 3a36e5eba..27d2b28a4 100644
--- a/sdk/go/arvados/user.go
+++ b/sdk/go/arvados/user.go
@@ -6,11 +6,11 @@ package arvados
 
 // User is an arvados#user record
 type User struct {
-	UUID     string `json:"uuid,omitempty"`
+	UUID     string `json:"uuid"`
 	IsActive bool   `json:"is_active"`
 	IsAdmin  bool   `json:"is_admin"`
-	Username string `json:"username,omitempty"`
-	Email    string `json:"email,omitempty"`
+	Username string `json:"username"`
+	Email    string `json:"email"`
 }
 
 // UserList is an arvados#userList resource.
diff --git a/sdk/go/arvados/workflow.go b/sdk/go/arvados/workflow.go
index 09c8c71e8..5ddc8732d 100644
--- a/sdk/go/arvados/workflow.go
+++ b/sdk/go/arvados/workflow.go
@@ -8,13 +8,13 @@ import "time"
 
 // Workflow is an arvados#workflow resource.
 type Workflow struct {
-	UUID        string     `json:"uuid,omitempty"`
-	OwnerUUID   string     `json:"owner_uuid,omitempty"`
-	Name        string     `json:"name,omitempty"`
-	Description string     `json:"description,omitempty"`
-	Definition  string     `json:"definition,omitempty"`
-	CreatedAt   *time.Time `json:"created_at,omitempty"`
-	ModifiedAt  *time.Time `json:"modified_at,omitempty"`
+	UUID        string     `json:"uuid"`
+	OwnerUUID   string     `json:"owner_uuid"`
+	Name        string     `json:"name"`
+	Description string     `json:"description"`
+	Definition  string     `json:"definition"`
+	CreatedAt   *time.Time `json:"created_at"`
+	ModifiedAt  *time.Time `json:"modified_at"`
 }
 
 // WorkflowList is an arvados#workflowList resource.

commit d3effc86db50342c59740b47961d12ee21abc966
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Wed May 8 11:43:33 2019 -0400

    14287: Ensure timestamps in responses have 9 digits of nanoseconds.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/response.go b/lib/controller/router/response.go
index 65e0159fa..ddbeee666 100644
--- a/lib/controller/router/response.go
+++ b/lib/controller/router/response.go
@@ -7,11 +7,15 @@ package router
 import (
 	"encoding/json"
 	"net/http"
+	"strings"
+	"time"
 
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/httpserver"
 )
 
+const rfc3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
+
 type responseOptions struct {
 	Select []string
 }
@@ -41,6 +45,29 @@ func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts re
 		}
 		tmp = selected
 	}
+	// Format non-nil timestamps as rfc3339NanoFixed (by default
+	// they will have been encoded to time.RFC3339Nano, which
+	// omits trailing zeroes).
+	for k, v := range tmp {
+		if !strings.HasSuffix(k, "_at") {
+			continue
+		}
+		switch tv := v.(type) {
+		case *time.Time:
+			if tv == nil {
+				break
+			}
+			tmp[k] = tv.Format(rfc3339NanoFixed)
+		case time.Time:
+			tmp[k] = tv.Format(rfc3339NanoFixed)
+		case string:
+			t, err := time.Parse(time.RFC3339Nano, tv)
+			if err != nil {
+				break
+			}
+			tmp[k] = t.Format(rfc3339NanoFixed)
+		}
+	}
 	json.NewEncoder(w).Encode(tmp)
 }
 

commit 9bb21fc0fb6fb3b55ae751125328b8214a9502cd
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Wed May 8 11:36:29 2019 -0400

    14287: Fix accepting JSON-encoded params in request body.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/request.go b/lib/controller/router/request.go
index 8ea253e6c..aa2cd636c 100644
--- a/lib/controller/router/request.go
+++ b/lib/controller/router/request.go
@@ -6,6 +6,7 @@ package router
 
 import (
 	"encoding/json"
+	"fmt"
 	"io"
 	"mime"
 	"net/http"
@@ -66,9 +67,15 @@ func (rtr *router) loadRequestParams(req *http.Request, attrsKey string) (map[st
 			// as foo=["bar","baz"]?
 		}
 	}
-	if ct, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")); err != nil && ct == "application/json" {
+
+	// Decode body as JSON if Content-Type request header is
+	// missing or application/json.
+	mt := req.Header.Get("Content-Type")
+	if ct, _, err := mime.ParseMediaType(mt); err != nil && mt != "" {
+		return nil, fmt.Errorf("error parsing media type %q: %s", mt, err)
+	} else if (ct == "application/json" || mt == "") && req.ContentLength != 0 {
 		jsonParams := map[string]interface{}{}
-		err := json.NewDecoder(req.Body).Decode(jsonParams)
+		err := json.NewDecoder(req.Body).Decode(&jsonParams)
 		if err != nil {
 			return nil, httpError(http.StatusBadRequest, err)
 		}
diff --git a/lib/controller/router/request_test.go b/lib/controller/router/request_test.go
new file mode 100644
index 000000000..cffdccc90
--- /dev/null
+++ b/lib/controller/router/request_test.go
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+	"bytes"
+	"net/http/httptest"
+
+	check "gopkg.in/check.v1"
+)
+
+func (s *RouterSuite) TestAttrsInBody(c *check.C) {
+	for _, body := range []string{
+		`{"foo":"bar"}`,
+		`{"model_name": {"foo":"bar"}}`,
+	} {
+		c.Logf("body: %s", body)
+		req := httptest.NewRequest("POST", "https://an.example/ctrl", bytes.NewBufferString(body))
+		req.Header.Set("Content-Type", "application/json")
+		params, err := s.rtr.loadRequestParams(req, "model_name")
+		c.Assert(err, check.IsNil)
+		c.Logf("params: %#v", params)
+		c.Check(params, check.NotNil)
+		c.Assert(params["attrs"], check.FitsTypeOf, map[string]interface{}{})
+		c.Check(params["attrs"].(map[string]interface{})["foo"], check.Equals, "bar")
+	}
+}
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 2e0c52b79..686b8933a 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -65,7 +65,7 @@ func (s *RouterSuite) TestCollectionParams(c *check.C) {
 	c.Check(rw.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
 
-	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections`, nil, bytes.NewBufferString(`ensure_unique_name=true`))
+	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections`, http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, bytes.NewBufferString(`ensure_unique_name=true`))
 	c.Check(rw.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["uuid"], check.FitsTypeOf, "")
 

commit 17f96aa64f76766c58a16071d0e02deb2710d24a
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Wed May 8 11:34:58 2019 -0400

    14287: Fix PDH check to ignore additional hints.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
index a08ec48f4..ad46d8788 100644
--- a/lib/controller/federation/conn.go
+++ b/lib/controller/federation/conn.go
@@ -211,7 +211,10 @@ func (conn *Conn) CollectionGet(ctx context.Context, options arvados.GetOptions)
 			if err != nil {
 				return err
 			}
-			if pdh := portableDataHash(c.ManifestText); pdh != options.UUID {
+			// options.UUID is either hash+size or
+			// hash+size+hints; only hash+size need to
+			// match the computed PDH.
+			if pdh := portableDataHash(c.ManifestText); pdh != options.UUID && !strings.HasPrefix(options.UUID, pdh+"+") {
 				ctxlog.FromContext(ctx).Warnf("bad portable data hash %q received from remote %q (expected %q)", pdh, remoteID, options.UUID)
 				return notFoundError{}
 			}

commit bb49b690681b72d2763c89b22e2af06845ca351e
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 7 15:49:57 2019 -0400

    14287: Fix PDHs and manifests in test fixtures.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/apps/workbench/test/controllers/container_requests_controller_test.rb b/apps/workbench/test/controllers/container_requests_controller_test.rb
index 6e96839e2..6940a0907 100644
--- a/apps/workbench/test/controllers/container_requests_controller_test.rb
+++ b/apps/workbench/test/controllers/container_requests_controller_test.rb
@@ -138,7 +138,7 @@ class ContainerRequestsControllerTest < ActionController::TestCase
     assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/foobar\?" # locator on command
     assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/foo" # mount input1
     assert_includes @response.body, "href=\"\/collections/fa7aeb5140e2848d39b416daeef4ffc5+45/bar" # mount input2
-    assert_includes @response.body, "href=\"\/collections/1fd08fc162a5c6413070a8bd0bffc818+150" # mount workflow
+    assert_includes @response.body, "href=\"\/collections/f9ddda46bb293b6847da984e3aa735db+290" # mount workflow
     assert_includes @response.body, "href=\"#Log\""
     assert_includes @response.body, "href=\"#Provenance\""
   end
diff --git a/sdk/go/arvadostest/fixtures.go b/sdk/go/arvadostest/fixtures.go
index f2f457448..37633ed23 100644
--- a/sdk/go/arvadostest/fixtures.go
+++ b/sdk/go/arvadostest/fixtures.go
@@ -33,7 +33,7 @@ const (
 	ASubprojectUUID = "zzzzz-j7d0g-axqo7eu9pwvna1x"
 
 	FooAndBarFilesInDirUUID = "zzzzz-4zz18-foonbarfilesdir"
-	FooAndBarFilesInDirPDH  = "6bbac24198d09a93975f60098caf0bdf+62"
+	FooAndBarFilesInDirPDH  = "870369fc72738603c2fad16664e50e2d+58"
 
 	Dispatch1Token    = "kwi8oowusvbutahacwk2geulqewy5oaqmpalczfna4b6bb0hfw"
 	Dispatch1AuthUUID = "zzzzz-gj3su-k9dvestay1plssr"
diff --git a/services/api/test/fixtures/collections.yml b/services/api/test/fixtures/collections.yml
index c84e479e4..5024ecc96 100644
--- a/services/api/test/fixtures/collections.yml
+++ b/services/api/test/fixtures/collections.yml
@@ -129,7 +129,7 @@ w_a_z_file_version_1:
 multilevel_collection_1:
   uuid: zzzzz-4zz18-pyw8yp9g3pr7irn
   current_version_uuid: zzzzz-4zz18-pyw8yp9g3pr7irn
-  portable_data_hash: 1fd08fc162a5c6413070a8bd0bffc818+150
+  portable_data_hash: f9ddda46bb293b6847da984e3aa735db+290
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -143,7 +143,7 @@ multilevel_collection_2:
   uuid: zzzzz-4zz18-45xf9hw1sxkhl6q
   current_version_uuid: zzzzz-4zz18-45xf9hw1sxkhl6q
   # All of this collection's files are deep in subdirectories.
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 8591cc5caeca80fc62fd529ba1d63bf3+118
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -394,7 +394,7 @@ unique_expired_collection:
 unique_expired_collection2:
   uuid: zzzzz-4zz18-mto52zx1s7sn3jr
   current_version_uuid: zzzzz-4zz18-mto52zx1s7sn3jr
-  portable_data_hash: 4ad199f90029935844dc3f098f4fca2b+49
+  portable_data_hash: 64a2bed1ef0f40fe3a7d39bcf2584cb8+50
   owner_uuid: zzzzz-tpzed-000000000000000
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -404,7 +404,7 @@ unique_expired_collection2:
   is_trashed: true
   trash_at: 2001-01-01T00:00:00Z
   delete_at: 2038-01-01T00:00:00Z
-  manifest_text: ". 29d7797f1888013986899bc9083783fa+3 0:3:expired\n"
+  manifest_text: ". 29d7797f1888013986899bc9083783fa+3 0:3:expired2\n"
   name: unique_expired_collection2
 
 # a collection with a log file that can be parsed by the log viewer
@@ -474,14 +474,14 @@ collection_with_files_in_subdir:
   uuid: zzzzz-4zz18-filesinsubdir00
   current_version_uuid: zzzzz-4zz18-filesinsubdir00
   name: collection_files_in_subdir
-  portable_data_hash: 85877ca2d7e05498dd3d109baf2df106+95
+  portable_data_hash: 7eb64275355980ebc93411b44050c137+281
   owner_uuid: zzzzz-tpzed-user1withloadab
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
   modified_by_user_uuid: zzzzz-tpzed-user1withloadab
   modified_at: 2014-02-03T17:22:54Z
   updated_at: 2014-02-03T17:22:54Z
-  manifest_text: ". 85877ca2d7e05498dd3d109baf2df106+95 0:95:file_in_subdir1\n./subdir2/subdir3 2bbc341c702df4d8f42ec31f16c10120+64 0:32:file1_in_subdir3.txt 32:32:file2_in_subdir3.txt\n./subdir2/subdir3/subdir4 2bbc341c702df4d8f42ec31f16c10120+64 0:32:file1_in_subdir4.txt 32:32:file2_in_subdir4.txt"
+  manifest_text: ". 85877ca2d7e05498dd3d109baf2df106+95 0:95:file_in_subdir1\n./subdir2/subdir3 2bbc341c702df4d8f42ec31f16c10120+64 0:32:file1_in_subdir3.txt 32:32:file2_in_subdir3.txt\n./subdir2/subdir3/subdir4 2bbc341c702df4d8f42ec31f16c10120+64 0:32:file1_in_subdir4.txt 32:32:file2_in_subdir4.txt\n"
 
 graph_test_collection1:
   uuid: zzzzz-4zz18-bv31uwvy3neko22
@@ -722,7 +722,7 @@ collection_with_one_property:
 collection_with_repeated_filenames_and_contents_in_two_dirs_1:
   uuid: zzzzz-4zz18-duplicatenames1
   current_version_uuid: zzzzz-4zz18-duplicatenames1
-  portable_data_hash: f3a67fad3a19c31c658982fb8158fa58+144
+  portable_data_hash: ce437b12aa73ab34f7af5227f556c9e6+142
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -748,7 +748,7 @@ collection_with_repeated_filenames_and_contents_in_two_dirs_2:
 foo_and_bar_files_in_dir:
   uuid: zzzzz-4zz18-foonbarfilesdir
   current_version_uuid: zzzzz-4zz18-foonbarfilesdir
-  portable_data_hash: 6bbac24198d09a93975f60098caf0bdf+62
+  portable_data_hash: 870369fc72738603c2fad16664e50e2d+58
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -801,7 +801,7 @@ collection_with_several_unsupported_file_types:
 collection_not_readable_by_active:
   uuid: zzzzz-4zz18-cd42uwvy3neko21
   current_version_uuid: zzzzz-4zz18-cd42uwvy3neko21
-  portable_data_hash: bb89eb5140e2848d39b416daeef4ffc5+45
+  portable_data_hash: fa7aeb5140e2848d39b416daeef4ffc5+45
   owner_uuid: zzzzz-tpzed-000000000000000
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -814,7 +814,7 @@ collection_not_readable_by_active:
 collection_to_remove_and_rename_files:
   uuid: zzzzz-4zz18-a21ux3541sxa8sf
   current_version_uuid: zzzzz-4zz18-a21ux3541sxa8sf
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 21aed8fd508bd6263704b673455949ba+57
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -843,7 +843,7 @@ collection_with_tags_owned_by_active:
 trashed_collection_to_test_name_conflict_on_untrash:
   uuid: zzzzz-4zz18-trashedcolnamec
   current_version_uuid: zzzzz-4zz18-trashedcolnamec
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 21aed8fd508bd6263704b673455949ba+57
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -859,7 +859,7 @@ trashed_collection_to_test_name_conflict_on_untrash:
 same_name_as_trashed_coll_to_test_name_conflict_on_untrash:
   uuid: zzzzz-4zz18-namesameastrash
   current_version_uuid: zzzzz-4zz18-namesameastrash
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 21aed8fd508bd6263704b673455949ba+57
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
@@ -872,7 +872,7 @@ same_name_as_trashed_coll_to_test_name_conflict_on_untrash:
 collection_in_trashed_subproject:
   uuid: zzzzz-4zz18-trashedproj2col
   current_version_uuid: zzzzz-4zz18-trashedproj2col
-  portable_data_hash: 80cf6dd2cf079dd13f272ec4245cb4a8+48
+  portable_data_hash: 21aed8fd508bd6263704b673455949ba+57
   owner_uuid: zzzzz-j7d0g-trashedproject2
   created_at: 2014-02-03T17:22:54Z
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
diff --git a/services/api/test/fixtures/container_requests.yml b/services/api/test/fixtures/container_requests.yml
index dea98887e..ea86dca17 100644
--- a/services/api/test/fixtures/container_requests.yml
+++ b/services/api/test/fixtures/container_requests.yml
@@ -322,7 +322,7 @@ completed_with_input_mounts:
           basename: bar
           class: File
           location: "keep:fa7aeb5140e2848d39b416daeef4ffc5+45/bar"
-    /var/lib/cwl/workflow.json: "keep:1fd08fc162a5c6413070a8bd0bffc818+150"
+    /var/lib/cwl/workflow.json: "keep:f9ddda46bb293b6847da984e3aa735db+290"
 
 uncommitted:
   uuid: zzzzz-xvhdp-cr4uncommittedc

commit 04cfcf38689e1a8b23fd4d35d9d5d6b75bc21f5d
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 7 15:24:36 2019 -0400

    14287: Fix unparsed formatting directive.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/cmdtest/leakcheck.go b/lib/cmdtest/leakcheck.go
index c132f1b36..ba4c3c123 100644
--- a/lib/cmdtest/leakcheck.go
+++ b/lib/cmdtest/leakcheck.go
@@ -43,7 +43,7 @@ func LeakCheck(c *check.C) func() {
 		os.Stdout, os.Stderr = stdout, stderr
 
 		for i, tmpfile := range tmpfiles {
-			c.Log("checking %s", i)
+			c.Logf("checking %s", i)
 			_, err := tmpfile.Seek(0, io.SeekStart)
 			c.Assert(err, check.IsNil)
 			leaked, err := ioutil.ReadAll(tmpfile)

commit b8456e13fb1395f67914a58c8ac2db80f25660b0
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 7 15:10:52 2019 -0400

    14287: Propagate order param.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 4cdf7c0e1..a1c790680 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -42,6 +42,7 @@ type ListOptions struct {
 	Filters []Filter `json:"filters"`
 	Limit   int      `json:"limit"`
 	Offset  int      `json:"offset"`
+	Order   string   `json:"order"`
 }
 
 type CreateOptions struct {

commit dc9a6a001080c982c3957a02cf76aa17502aab7b
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 7 13:50:52 2019 -0400

    14287: Test timestamp precision is maintained by response munging.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 9b04c2672..2e0c52b79 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -12,6 +12,7 @@ import (
 	"net/http/httptest"
 	"os"
 	"testing"
+	"time"
 
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
@@ -126,6 +127,27 @@ func (s *RouterSuite) TestContainerLock(c *check.C) {
 	c.Check(jresp["uuid"], check.IsNil)
 }
 
+func (s *RouterSuite) TestFullTimestampsInResponse(c *check.C) {
+	uuid := arvadostest.CollectionReplicationDesired2Confirmed2UUID
+	token := arvadostest.ActiveTokenV2
+
+	_, rw, jresp := s.doRequest(c, token, "GET", `/arvados/v1/collections/`+uuid, nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["uuid"], check.Equals, uuid)
+	expectNS := map[string]int{
+		"created_at":  596506000, // fixture says 596506247, but truncated by postgresql
+		"modified_at": 596338000, // fixture says 596338465, but truncated by postgresql
+	}
+	for key, ns := range expectNS {
+		mt, ok := jresp[key].(string)
+		c.Logf("jresp[%q] == %q", key, mt)
+		c.Assert(ok, check.Equals, true)
+		t, err := time.Parse(time.RFC3339Nano, mt)
+		c.Check(err, check.IsNil)
+		c.Check(t.Nanosecond(), check.Equals, ns)
+	}
+}
+
 func (s *RouterSuite) TestSelectParam(c *check.C) {
 	uuid := arvadostest.QueuedContainerUUID
 	token := arvadostest.ActiveTokenV2
diff --git a/sdk/go/arvadostest/fixtures.go b/sdk/go/arvadostest/fixtures.go
index 4f648e9b4..f2f457448 100644
--- a/sdk/go/arvadostest/fixtures.go
+++ b/sdk/go/arvadostest/fixtures.go
@@ -56,6 +56,8 @@ const (
 	FooCollectionSharingToken     = "iknqgmunrhgsyfok8uzjlwun9iscwm3xacmzmg65fa1j1lpdss"
 
 	WorkflowWithDefinitionYAMLUUID = "zzzzz-7fd4e-validworkfloyml"
+
+	CollectionReplicationDesired2Confirmed2UUID = "zzzzz-4zz18-434zv1tnnf2rygp"
 )
 
 // PathologicalManifest : A valid manifest designed to test

commit 199c4797fe3b3626a7548e4d4147c4729bd72d5a
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 7 09:47:01 2019 -0400

    14287: Fix token in container test.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 30c15c28e..9b04c2672 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -108,7 +108,7 @@ func (s *RouterSuite) TestContainerList(c *check.C) {
 
 func (s *RouterSuite) TestContainerLock(c *check.C) {
 	uuid := arvadostest.QueuedContainerUUID
-	token := arvadostest.ActiveTokenV2
+	token := arvadostest.AdminToken
 	_, rw, jresp := s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
 	c.Check(rw.Code, check.Equals, http.StatusOK)
 	c.Check(jresp["uuid"], check.HasLen, 27)

commit 58a959a40145a6b5ecc7c29002ceb4d5f7b33904
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 7 09:45:58 2019 -0400

    14287: Accept rpc requests without tokens.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
index 7c23ed170..9bb3eb33f 100644
--- a/lib/controller/rpc/conn.go
+++ b/lib/controller/rpc/conn.go
@@ -71,10 +71,15 @@ func (conn *Conn) requestAndDecode(ctx context.Context, dst interface{}, ep arva
 	tokens, err := conn.tokenProvider(ctx)
 	if err != nil {
 		return err
-	} else if len(tokens) == 0 {
-		return fmt.Errorf("bug: token provider returned no tokens and no error")
+	} else if len(tokens) > 0 {
+		ctx = context.WithValue(ctx, "Authorization", "Bearer "+tokens[0])
+	} else {
+		// Use a non-empty auth string to ensure we override
+		// any default token set on aClient -- and to avoid
+		// having the remote prompt us to send a token by
+		// responding 401.
+		ctx = context.WithValue(ctx, "Authorization", "Bearer -")
 	}
-	ctx = context.WithValue(ctx, "Authorization", "Bearer "+tokens[0])
 
 	// Encode opts to JSON and decode from there to a
 	// map[string]interface{}, so we can munge the query params

commit 0704c11d4818f1eee71671d82493e1d01cc76709
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 7 09:40:47 2019 -0400

    14287: Fix accepting nil as filter operand.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/sdk/go/arvados/resource_list.go b/sdk/go/arvados/resource_list.go
index 505ba51ec..d1a25c438 100644
--- a/sdk/go/arvados/resource_list.go
+++ b/sdk/go/arvados/resource_list.go
@@ -55,7 +55,7 @@ func (f *Filter) UnmarshalJSON(data []byte) error {
 	}
 	operand := elements[2]
 	switch operand.(type) {
-	case string, float64, []interface{}:
+	case string, float64, []interface{}, nil:
 	default:
 		return fmt.Errorf("invalid filter operand %q", elements[2])
 	}
diff --git a/sdk/go/arvados/resource_list_test.go b/sdk/go/arvados/resource_list_test.go
index 5642599b4..4e09c5375 100644
--- a/sdk/go/arvados/resource_list_test.go
+++ b/sdk/go/arvados/resource_list_test.go
@@ -23,3 +23,14 @@ func TestMarshalFiltersWithNanoseconds(t *testing.T) {
 		t.Errorf("Encoded as %q, expected %q", buf, expect)
 	}
 }
+
+func TestMarshalFiltersWithNil(t *testing.T) {
+	buf, err := json.Marshal([]Filter{
+		{Attr: "modified_at", Operator: "=", Operand: nil}})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if expect := []byte(`[["modified_at","=",null]]`); 0 != bytes.Compare(buf, expect) {
+		t.Errorf("Encoded as %q, expected %q", buf, expect)
+	}
+}

commit 7c57f91650bc33a35067ba9b74123a90aadfc39f
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Tue May 7 09:39:58 2019 -0400

    14287: Fix accepting boolean params via query string.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/lib/controller/router/request.go b/lib/controller/router/request.go
index 67d4e0ffb..8ea253e6c 100644
--- a/lib/controller/router/request.go
+++ b/lib/controller/router/request.go
@@ -29,6 +29,8 @@ func (rtr *router) loadRequestParams(req *http.Request, attrsKey string) (map[st
 	for k, values := range req.Form {
 		for _, v := range values {
 			switch {
+			case boolParams[k]:
+				params[k] = stringToBool(v)
 			case v == "null" || v == "":
 				params[k] = nil
 			case strings.HasPrefix(v, "["):
@@ -110,3 +112,18 @@ func (rtr *router) transcode(src interface{}, dst interface{}) error {
 	}
 	return err
 }
+
+var boolParams = map[string]bool{
+	"ensure_unique_name":   true,
+	"include_trash":        true,
+	"include_old_versions": true,
+}
+
+func stringToBool(s string) bool {
+	switch s {
+	case "", "false", "0":
+		return false
+	default:
+		return true
+	}
+}
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 97710d265..30c15c28e 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -5,6 +5,7 @@
 package router
 
 import (
+	"bytes"
 	"encoding/json"
 	"io"
 	"net/http"
@@ -52,6 +53,26 @@ func (s *RouterSuite) doRequest(c *check.C, token, method, path string, hdrs htt
 	return req, rw, jresp
 }
 
+func (s *RouterSuite) TestCollectionParams(c *check.C) {
+	token := arvadostest.ActiveTokenV2
+
+	_, rw, jresp := s.doRequest(c, token, "GET", `/arvados/v1/collections?include_trash=true`, nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+
+	_, rw, jresp = s.doRequest(c, token, "GET", `/arvados/v1/collections`, nil, bytes.NewBufferString(`{"include_trash":true}`))
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["items"], check.FitsTypeOf, []interface{}{})
+
+	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections`, nil, bytes.NewBufferString(`ensure_unique_name=true`))
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["uuid"], check.FitsTypeOf, "")
+
+	_, rw, jresp = s.doRequest(c, token, "POST", `/arvados/v1/collections?ensure_unique_name=true`, nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["uuid"], check.FitsTypeOf, "")
+}
+
 func (s *RouterSuite) TestContainerList(c *check.C) {
 	token := arvadostest.ActiveTokenV2
 

commit 92a42f3f715254305e9fe0f4a47cbc9ccc03fe54
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon May 6 16:44:12 2019 -0400

    14287: Dedup "UUIDs seen" list before diff in test.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/services/keep-balance/collection_test.go b/services/keep-balance/collection_test.go
index a548b1ff9..6aaf07aba 100644
--- a/services/keep-balance/collection_test.go
+++ b/services/keep-balance/collection_test.go
@@ -30,7 +30,6 @@ func (s *integrationSuite) TestIdenticalTimestamps(c *check.C) {
 			var lastMod time.Time
 			sawUUID := make(map[string]bool)
 			err := EachCollection(&s.config.Client, pageSize, func(c arvados.Collection) error {
-				got[trial] = append(got[trial], c.UUID)
 				if c.ModifiedAt == nil {
 					return nil
 				}
@@ -38,6 +37,7 @@ func (s *integrationSuite) TestIdenticalTimestamps(c *check.C) {
 					// dup
 					return nil
 				}
+				got[trial] = append(got[trial], c.UUID)
 				sawUUID[c.UUID] = true
 				if lastMod == *c.ModifiedAt {
 					streak++

commit a207f61584e69c429b3bcca46aa2a54999076dd0
Merge: d072b78d5 0d7f05d04
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Mon May 6 11:30:21 2019 -0400

    14287: Merge branch 'master' into 14287-controller-structure
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>


commit d072b78d52285f13c2599a290b773b7c56d44b80
Author: Tom Clegg <tclegg at veritasgenetics.com>
Date:   Thu Apr 11 16:20:13 2019 -0400

    14287: Refactor controller to use strong types in API handlers.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg at veritasgenetics.com>

diff --git a/build/run-tests.sh b/build/run-tests.sh
index a37a0f731..981b69528 100755
--- a/build/run-tests.sh
+++ b/build/run-tests.sh
@@ -77,6 +77,10 @@ doc
 lib/cli
 lib/cmd
 lib/controller
+lib/controller/federation
+lib/controller/railsproxy
+lib/controller/router
+lib/controller/rpc
 lib/crunchstat
 lib/cloud
 lib/cloud/azure
@@ -394,7 +398,7 @@ start_services() {
         return 0
     fi
     . "$VENVDIR/bin/activate"
-    echo 'Starting API, keepproxy, keep-web, ws, arv-git-httpd, and nginx ssl proxy...'
+    echo 'Starting API, controller, keepproxy, keep-web, arv-git-httpd, ws, and nginx ssl proxy...'
     if [[ ! -d "$WORKSPACE/services/api/log" ]]; then
 	mkdir -p "$WORKSPACE/services/api/log"
     fi
@@ -821,6 +825,7 @@ do_install_once() {
     title "install $1"
     timer_reset
 
+    result=
     if which deactivate >/dev/null; then deactivate; fi
     if [[ "$1" != "env" ]] && ! . "$VENVDIR/bin/activate"; then
         result=1
@@ -974,50 +979,7 @@ pythonstuff=(
 )
 
 declare -a gostuff
-gostuff=(
-    cmd/arvados-client
-    cmd/arvados-server
-    lib/cli
-    lib/cmd
-    lib/controller
-    lib/crunchstat
-    lib/cloud
-    lib/cloud/azure
-    lib/cloud/ec2
-    lib/dispatchcloud
-    lib/dispatchcloud/container
-    lib/dispatchcloud/scheduler
-    lib/dispatchcloud/ssh_executor
-    lib/dispatchcloud/worker
-    lib/service
-    sdk/go/arvados
-    sdk/go/arvadosclient
-    sdk/go/auth
-    sdk/go/blockdigest
-    sdk/go/dispatch
-    sdk/go/health
-    sdk/go/httpserver
-    sdk/go/manifest
-    sdk/go/asyncbuf
-    sdk/go/crunchrunner
-    sdk/go/stats
-    services/arv-git-httpd
-    services/crunchstat
-    services/health
-    services/keep-web
-    services/keepstore
-    sdk/go/keepclient
-    services/keep-balance
-    services/keepproxy
-    services/crunch-dispatch-local
-    services/crunch-dispatch-slurm
-    services/crunch-run
-    services/ws
-    tools/keep-block-check
-    tools/keep-exercise
-    tools/keep-rsync
-    tools/sync-groups
-)
+gostuff=($(git grep -lw func | grep \\.go | sed -e 's/\/[^\/]*$//' | sort -u))
 
 install_apps/workbench() {
     cd "$WORKSPACE/apps/workbench" \
diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
new file mode 100644
index 000000000..a08ec48f4
--- /dev/null
+++ b/lib/controller/federation/conn.go
@@ -0,0 +1,309 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package federation
+
+import (
+	"context"
+	"crypto/md5"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"regexp"
+	"strings"
+
+	"git.curoverse.com/arvados.git/lib/controller/railsproxy"
+	"git.curoverse.com/arvados.git/lib/controller/rpc"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/auth"
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
+)
+
+type Interface interface {
+	CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error)
+	CollectionUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Collection, error)
+	CollectionGet(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error)
+	CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error)
+	CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error)
+	ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error)
+	ContainerUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Container, error)
+	ContainerGet(ctx context.Context, options arvados.GetOptions) (arvados.Container, error)
+	ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error)
+	ContainerDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Container, error)
+	ContainerLock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error)
+	ContainerUnlock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error)
+	SpecimenCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Specimen, error)
+	SpecimenUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Specimen, error)
+	SpecimenGet(ctx context.Context, options arvados.GetOptions) (arvados.Specimen, error)
+	SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error)
+	SpecimenDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Specimen, error)
+	APIClientAuthorizationCurrent(ctx context.Context, options arvados.GetOptions) (arvados.APIClientAuthorization, error)
+}
+
+type Conn struct {
+	cluster *arvados.Cluster
+	local   backend
+	remotes map[string]backend
+}
+
+func New(cluster *arvados.Cluster, np *arvados.NodeProfile) Interface {
+	local := railsproxy.NewConn(cluster, np)
+	remotes := map[string]backend{}
+	for id, remote := range cluster.RemoteClusters {
+		if !remote.Proxy {
+			continue
+		}
+		remotes[id] = rpc.NewConn(id, &url.URL{Scheme: remote.Scheme, Host: remote.Host}, remote.Insecure, saltedTokenProvider(local, id))
+	}
+
+	return &Conn{
+		cluster: cluster,
+		local:   local,
+		remotes: remotes,
+	}
+}
+
+// Return a new rpc.TokenProvider that takes the client-provided
+// tokens from an incoming request context, determines whether they
+// should (and can) be salted for the given remoteID, and returns the
+// resulting tokens.
+func saltedTokenProvider(local backend, remoteID string) rpc.TokenProvider {
+	return func(ctx context.Context) ([]string, error) {
+		var tokens []string
+		incoming, ok := ctx.Value(auth.ContextKeyCredentials).(*auth.Credentials)
+		if !ok {
+			return nil, errors.New("no token provided")
+		}
+		for _, token := range incoming.Tokens {
+			salted, err := auth.SaltToken(token, remoteID)
+			switch err {
+			case nil:
+				tokens = append(tokens, salted)
+			case auth.ErrSalted:
+				tokens = append(tokens, token)
+			case auth.ErrObsoleteToken:
+				ctx := context.WithValue(ctx, auth.ContextKeyCredentials, &auth.Credentials{Tokens: []string{token}})
+				aca, err := local.APIClientAuthorizationCurrent(ctx, arvados.GetOptions{})
+				if errStatus(err) == http.StatusUnauthorized {
+					// pass through unmodified
+					tokens = append(tokens, token)
+					continue
+				} else if err != nil {
+					return nil, err
+				}
+				salted, err := auth.SaltToken(aca.TokenV2(), remoteID)
+				if err != nil {
+					return nil, err
+				}
+				tokens = append(tokens, salted)
+			default:
+				return nil, err
+			}
+		}
+		return tokens, nil
+	}
+}
+
+// Return suitable backend for a query about the given cluster ID
+// ("aaaaa") or object UUID ("aaaaa-dz642-abcdefghijklmno").
+func (conn *Conn) chooseBackend(id string) backend {
+	if len(id) > 5 {
+		id = id[:5]
+	}
+	if id == conn.cluster.ClusterID {
+		return conn.local
+	} else if be, ok := conn.remotes[id]; ok {
+		return be
+	} else {
+		// TODO: return an "always error" backend?
+		return conn.local
+	}
+}
+
+// Call fn with the local backend; then, if fn returned 404, call fn
+// on the available remote backends (possibly concurrently) until one
+// succeeds.
+//
+// The second argument to fn is the cluster ID of the remote backend,
+// or "" for the local backend.
+//
+// A non-nil error means all backends failed.
+func (conn *Conn) tryLocalThenRemotes(ctx context.Context, fn func(context.Context, string, backend) error) error {
+	if err := fn(ctx, "", conn.local); err == nil || errStatus(err) != http.StatusNotFound {
+		return err
+	}
+
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+	errchan := make(chan error, len(conn.remotes))
+	for remoteID, be := range conn.remotes {
+		remoteID, be := remoteID, be
+		go func() {
+			errchan <- fn(ctx, remoteID, be)
+		}()
+	}
+	all404 := true
+	var errs []error
+	for i := 0; i < cap(errchan); i++ {
+		err := <-errchan
+		if err == nil {
+			return nil
+		}
+		all404 = all404 && errStatus(err) == http.StatusNotFound
+		errs = append(errs, err)
+	}
+	if all404 {
+		return notFoundError{}
+	}
+	// FIXME: choose appropriate HTTP status
+	return fmt.Errorf("errors: %v", errs)
+}
+
+func (conn *Conn) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
+	return conn.chooseBackend(options.ClusterID).CollectionCreate(ctx, options)
+}
+
+func (conn *Conn) CollectionUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Collection, error) {
+	return conn.chooseBackend(options.UUID).CollectionUpdate(ctx, options)
+}
+
+func rewriteManifest(mt, remoteID string) string {
+	return regexp.MustCompile(` [0-9a-f]{32}\+[^ ]*`).ReplaceAllStringFunc(mt, func(tok string) string {
+		return strings.Replace(tok, "+A", "+R"+remoteID+"-", -1)
+	})
+}
+
+// this could be in sdk/go/arvados
+func portableDataHash(mt string) string {
+	h := md5.New()
+	blkRe := regexp.MustCompile(`^ [0-9a-f]{32}\+\d+`)
+	size := 0
+	_ = regexp.MustCompile(` ?[^ ]*`).ReplaceAllFunc([]byte(mt), func(tok []byte) []byte {
+		if m := blkRe.Find(tok); m != nil {
+			// write hash+size, ignore remaining block hints
+			tok = m
+		}
+		n, err := h.Write(tok)
+		if err != nil {
+			panic(err)
+		}
+		size += n
+		return nil
+	})
+	return fmt.Sprintf("%x+%d", h.Sum(nil), size)
+}
+
+func (conn *Conn) CollectionGet(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
+	if len(options.UUID) == 27 {
+		// UUID is really a UUID
+		c, err := conn.chooseBackend(options.UUID).CollectionGet(ctx, options)
+		if err == nil && options.UUID[:5] != conn.cluster.ClusterID {
+			c.ManifestText = rewriteManifest(c.ManifestText, options.UUID[:5])
+		}
+		return c, err
+	} else {
+		// UUID is a PDH
+		first := make(chan arvados.Collection, 1)
+		err := conn.tryLocalThenRemotes(ctx, func(ctx context.Context, remoteID string, be backend) error {
+			c, err := be.CollectionGet(ctx, options)
+			if err != nil {
+				return err
+			}
+			if pdh := portableDataHash(c.ManifestText); pdh != options.UUID {
+				ctxlog.FromContext(ctx).Warnf("bad portable data hash %q received from remote %q (expected %q)", pdh, remoteID, options.UUID)
+				return notFoundError{}
+			}
+			if remoteID != "" {
+				c.ManifestText = rewriteManifest(c.ManifestText, remoteID)
+			}
+			select {
+			case first <- c:
+				return nil
+			default:
+				// lost race, return value doesn't matter
+				return nil
+			}
+		})
+		if err != nil {
+			return arvados.Collection{}, err
+		}
+		return <-first, nil
+	}
+}
+
+func (conn *Conn) CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error) {
+	return conn.local.CollectionList(ctx, options)
+}
+
+func (conn *Conn) CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
+	return conn.chooseBackend(options.UUID).CollectionDelete(ctx, options)
+}
+
+func (conn *Conn) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
+	return conn.chooseBackend(options.ClusterID).ContainerCreate(ctx, options)
+}
+
+func (conn *Conn) ContainerUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Container, error) {
+	return conn.chooseBackend(options.UUID).ContainerUpdate(ctx, options)
+}
+
+func (conn *Conn) ContainerGet(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+	return conn.chooseBackend(options.UUID).ContainerGet(ctx, options)
+}
+
+func (conn *Conn) ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
+	return conn.local.ContainerList(ctx, options)
+}
+
+func (conn *Conn) ContainerDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Container, error) {
+	return conn.chooseBackend(options.UUID).ContainerDelete(ctx, options)
+}
+
+func (conn *Conn) ContainerLock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+	return conn.chooseBackend(options.UUID).ContainerLock(ctx, options)
+}
+
+func (conn *Conn) ContainerUnlock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+	return conn.chooseBackend(options.UUID).ContainerUnlock(ctx, options)
+}
+
+func (conn *Conn) SpecimenCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Specimen, error) {
+	return conn.chooseBackend(options.ClusterID).SpecimenCreate(ctx, options)
+}
+
+func (conn *Conn) SpecimenUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Specimen, error) {
+	return conn.chooseBackend(options.UUID).SpecimenUpdate(ctx, options)
+}
+
+func (conn *Conn) SpecimenGet(ctx context.Context, options arvados.GetOptions) (arvados.Specimen, error) {
+	return conn.chooseBackend(options.UUID).SpecimenGet(ctx, options)
+}
+
+func (conn *Conn) SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
+	return conn.local.SpecimenList(ctx, options)
+}
+
+func (conn *Conn) SpecimenDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Specimen, error) {
+	return conn.chooseBackend(options.UUID).SpecimenDelete(ctx, options)
+}
+
+func (conn *Conn) APIClientAuthorizationCurrent(ctx context.Context, options arvados.GetOptions) (arvados.APIClientAuthorization, error) {
+	return conn.chooseBackend(options.UUID).APIClientAuthorizationCurrent(ctx, options)
+}
+
+type backend interface{ Interface }
+
+type notFoundError struct{}
+
+func (notFoundError) HTTPStatus() int { return http.StatusNotFound }
+func (notFoundError) Error() string   { return "not found" }
+
+func errStatus(err error) int {
+	if httpErr, ok := err.(interface{ HTTPStatus() int }); ok {
+		return httpErr.HTTPStatus()
+	} else {
+		return http.StatusInternalServerError
+	}
+}
diff --git a/lib/controller/federation_test.go b/lib/controller/federation_test.go
index 62916acd2..06c8f0086 100644
--- a/lib/controller/federation_test.go
+++ b/lib/controller/federation_test.go
@@ -39,7 +39,8 @@ type FederationSuite struct {
 	// provided by the integration test environment.
 	remoteServer *httpserver.Server
 	// remoteMock ("zmock") appends each incoming request to
-	// remoteMockRequests, and returns an empty 200 response.
+	// remoteMockRequests, and returns 200 with an empty JSON
+	// object.
 	remoteMock         *httpserver.Server
 	remoteMockRequests []http.Request
 }
@@ -68,6 +69,7 @@ func (s *FederationSuite) SetUpTest(c *check.C) {
 			MaxItemsPerResponse:            1000,
 			MultiClusterRequestConcurrency: 4,
 		},
+		EnableBetaController14287: enableBetaController14287,
 	}, NodeProfile: &nodeProfile}
 	s.testServer = newServerFromIntegrationTestEnv(c)
 	s.testServer.Server.Handler = httpserver.AddRequestIDs(httpserver.LogRequests(s.log, s.testHandler))
@@ -96,6 +98,8 @@ func (s *FederationSuite) remoteMockHandler(w http.ResponseWriter, req *http.Req
 	req.Body.Close()
 	req.Body = ioutil.NopCloser(b)
 	s.remoteMockRequests = append(s.remoteMockRequests, *req)
+	// Repond 200 with a valid JSON object
+	fmt.Fprint(w, "{}")
 }
 
 func (s *FederationSuite) TearDownTest(c *check.C) {
@@ -107,15 +111,15 @@ func (s *FederationSuite) TearDownTest(c *check.C) {
 	}
 }
 
-func (s *FederationSuite) testRequest(req *http.Request) *http.Response {
+func (s *FederationSuite) testRequest(req *http.Request) *httptest.ResponseRecorder {
 	resp := httptest.NewRecorder()
 	s.testServer.Server.Handler.ServeHTTP(resp, req)
-	return resp.Result()
+	return resp
 }
 
 func (s *FederationSuite) TestLocalRequest(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zhome-", 1), nil)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	s.checkHandledLocally(c, resp)
 }
 
@@ -130,7 +134,7 @@ func (s *FederationSuite) checkHandledLocally(c *check.C, resp *http.Response) {
 
 func (s *FederationSuite) TestNoAuth(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusUnauthorized)
 	s.checkJSONErrorMatches(c, resp, `Not logged in`)
 }
@@ -138,7 +142,7 @@ func (s *FederationSuite) TestNoAuth(c *check.C) {
 func (s *FederationSuite) TestBadAuth(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
 	req.Header.Set("Authorization", "Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusUnauthorized)
 	s.checkJSONErrorMatches(c, resp, `Not logged in`)
 }
@@ -146,7 +150,7 @@ func (s *FederationSuite) TestBadAuth(c *check.C) {
 func (s *FederationSuite) TestNoAccess(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.SpectatorToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
 	s.checkJSONErrorMatches(c, resp, `.*not found`)
 }
@@ -154,7 +158,7 @@ func (s *FederationSuite) TestNoAccess(c *check.C) {
 func (s *FederationSuite) TestGetUnknownRemote(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zz404-", 1), nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
 	s.checkJSONErrorMatches(c, resp, `.*no proxy available for cluster zz404`)
 }
@@ -166,7 +170,7 @@ func (s *FederationSuite) TestRemoteError(c *check.C) {
 
 	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusBadGateway)
 	s.checkJSONErrorMatches(c, resp, `.*HTTP response to HTTPS client`)
 }
@@ -174,7 +178,7 @@ func (s *FederationSuite) TestRemoteError(c *check.C) {
 func (s *FederationSuite) TestGetRemoteWorkflow(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var wf arvados.Workflow
 	c.Check(json.NewDecoder(resp.Body).Decode(&wf), check.IsNil)
@@ -185,7 +189,7 @@ func (s *FederationSuite) TestGetRemoteWorkflow(c *check.C) {
 func (s *FederationSuite) TestOptionsMethod(c *check.C) {
 	req := httptest.NewRequest("OPTIONS", "/arvados/v1/workflows/"+arvadostest.WorkflowWithDefinitionYAMLUUID, nil)
 	req.Header.Set("Origin", "https://example.com")
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	body, err := ioutil.ReadAll(resp.Body)
 	c.Check(err, check.IsNil)
@@ -201,7 +205,7 @@ func (s *FederationSuite) TestOptionsMethod(c *check.C) {
 
 func (s *FederationSuite) TestRemoteWithTokenInQuery(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1)+"?api_token="+arvadostest.ActiveToken, nil)
-	s.testRequest(req)
+	s.testRequest(req).Result()
 	c.Assert(s.remoteMockRequests, check.HasLen, 1)
 	pr := s.remoteMockRequests[0]
 	// Token is salted and moved from query to Authorization header.
@@ -210,28 +214,51 @@ func (s *FederationSuite) TestRemoteWithTokenInQuery(c *check.C) {
 }
 
 func (s *FederationSuite) TestLocalTokenSalted(c *check.C) {
-	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1), nil)
-	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	s.testRequest(req)
-	c.Assert(s.remoteMockRequests, check.HasLen, 1)
-	pr := s.remoteMockRequests[0]
-	// The salted token here has a "zzzzz-" UUID instead of a
-	// "ztest-" UUID because ztest's local database has the
-	// "zzzzz-" test fixtures. The "secret" part is HMAC(sha1,
-	// arvadostest.ActiveToken, "zmock") = "7fd3...".
-	c.Check(pr.Header.Get("Authorization"), check.Equals, "Bearer v2/zzzzz-gj3su-077z32aux8dg2s1/7fd31b61f39c0e82a4155592163218272cedacdc")
+	defer s.localServiceReturns404(c).Close()
+	for _, path := range []string{
+		// During the transition to the strongly typed
+		// controller implementation (#14287), workflows and
+		// collections test different code paths.
+		"/arvados/v1/workflows/" + strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1),
+		"/arvados/v1/collections/" + strings.Replace(arvadostest.UserAgreementCollection, "zzzzz-", "zmock-", 1),
+	} {
+		c.Log("testing path ", path)
+		s.remoteMockRequests = nil
+		req := httptest.NewRequest("GET", path, nil)
+		req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
+		s.testRequest(req).Result()
+		c.Assert(s.remoteMockRequests, check.HasLen, 1)
+		pr := s.remoteMockRequests[0]
+		// The salted token here has a "zzzzz-" UUID instead of a
+		// "ztest-" UUID because ztest's local database has the
+		// "zzzzz-" test fixtures. The "secret" part is HMAC(sha1,
+		// arvadostest.ActiveToken, "zmock") = "7fd3...".
+		c.Check(pr.Header.Get("Authorization"), check.Equals, "Bearer v2/zzzzz-gj3su-077z32aux8dg2s1/7fd31b61f39c0e82a4155592163218272cedacdc")
+	}
 }
 
 func (s *FederationSuite) TestRemoteTokenNotSalted(c *check.C) {
+	defer s.localServiceReturns404(c).Close()
 	// remoteToken can be any v1 token that doesn't appear in
 	// ztest's local db.
 	remoteToken := "abcdef00000000000000000000000000000000000000000000"
-	req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1), nil)
-	req.Header.Set("Authorization", "Bearer "+remoteToken)
-	s.testRequest(req)
-	c.Assert(s.remoteMockRequests, check.HasLen, 1)
-	pr := s.remoteMockRequests[0]
-	c.Check(pr.Header.Get("Authorization"), check.Equals, "Bearer "+remoteToken)
+
+	for _, path := range []string{
+		// During the transition to the strongly typed
+		// controller implementation (#14287), workflows and
+		// collections test different code paths.
+		"/arvados/v1/workflows/" + strings.Replace(arvadostest.WorkflowWithDefinitionYAMLUUID, "zzzzz-", "zmock-", 1),
+		"/arvados/v1/collections/" + strings.Replace(arvadostest.UserAgreementCollection, "zzzzz-", "zmock-", 1),
+	} {
+		c.Log("testing path ", path)
+		s.remoteMockRequests = nil
+		req := httptest.NewRequest("GET", path, nil)
+		req.Header.Set("Authorization", "Bearer "+remoteToken)
+		s.testRequest(req).Result()
+		c.Assert(s.remoteMockRequests, check.HasLen, 1)
+		pr := s.remoteMockRequests[0]
+		c.Check(pr.Header.Get("Authorization"), check.Equals, "Bearer "+remoteToken)
+	}
 }
 
 func (s *FederationSuite) TestWorkflowCRUD(c *check.C) {
@@ -273,7 +300,7 @@ func (s *FederationSuite) TestWorkflowCRUD(c *check.C) {
 		req := httptest.NewRequest(method, "/arvados/v1/workflows/"+wf.UUID, strings.NewReader(form.Encode()))
 		req.Header.Set("Content-type", "application/x-www-form-urlencoded")
 		req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-		resp := s.testRequest(req)
+		resp := s.testRequest(req).Result()
 		s.checkResponseOK(c, resp)
 		err := json.NewDecoder(resp.Body).Decode(&wf)
 		c.Check(err, check.IsNil)
@@ -283,7 +310,7 @@ func (s *FederationSuite) TestWorkflowCRUD(c *check.C) {
 	{
 		req := httptest.NewRequest("DELETE", "/arvados/v1/workflows/"+wf.UUID, nil)
 		req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-		resp := s.testRequest(req)
+		resp := s.testRequest(req).Result()
 		s.checkResponseOK(c, resp)
 		err := json.NewDecoder(resp.Body).Decode(&wf)
 		c.Check(err, check.IsNil)
@@ -291,7 +318,7 @@ func (s *FederationSuite) TestWorkflowCRUD(c *check.C) {
 	{
 		req := httptest.NewRequest("GET", "/arvados/v1/workflows/"+wf.UUID, nil)
 		req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-		resp := s.testRequest(req)
+		resp := s.testRequest(req).Result()
 		c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
 	}
 }
@@ -333,7 +360,15 @@ func (s *FederationSuite) localServiceHandler(c *check.C, h http.Handler) *https
 
 func (s *FederationSuite) localServiceReturns404(c *check.C) *httpserver.Server {
 	return s.localServiceHandler(c, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-		w.WriteHeader(404)
+		if req.URL.Path == "/arvados/v1/api_client_authorizations/current" {
+			if req.Header.Get("Authorization") == "Bearer "+arvadostest.ActiveToken {
+				json.NewEncoder(w).Encode(arvados.APIClientAuthorization{UUID: arvadostest.ActiveTokenUUID, APIToken: arvadostest.ActiveToken})
+			} else {
+				w.WriteHeader(http.StatusUnauthorized)
+			}
+		} else {
+			w.WriteHeader(404)
+		}
 	}))
 }
 
@@ -350,7 +385,7 @@ func (s *FederationSuite) TestGetLocalCollection(c *check.C) {
 
 	req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementCollection, nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var col arvados.Collection
@@ -367,7 +402,7 @@ func (s *FederationSuite) TestGetLocalCollection(c *check.C) {
 	}).Encode()))
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
-	resp = s.testRequest(req)
+	resp = s.testRequest(req).Result()
 
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	col = arvados.Collection{}
@@ -383,7 +418,7 @@ func (s *FederationSuite) TestGetRemoteCollection(c *check.C) {
 
 	req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementCollection, nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var col arvados.Collection
 	c.Check(json.NewDecoder(resp.Body).Decode(&col), check.IsNil)
@@ -398,7 +433,7 @@ func (s *FederationSuite) TestGetRemoteCollectionError(c *check.C) {
 
 	req := httptest.NewRequest("GET", "/arvados/v1/collections/zzzzz-4zz18-fakefakefakefak", nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
 }
 
@@ -425,7 +460,7 @@ func (s *FederationSuite) TestGetLocalCollectionByPDH(c *check.C) {
 
 	req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementPDH, nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var col arvados.Collection
@@ -441,7 +476,7 @@ func (s *FederationSuite) TestGetRemoteCollectionByPDH(c *check.C) {
 
 	req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementPDH, nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 
@@ -459,7 +494,7 @@ func (s *FederationSuite) TestGetCollectionByPDHError(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/collections/99999999999999999999999999999999+99", nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	defer resp.Body.Close()
 
 	c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
@@ -498,7 +533,7 @@ func (s *FederationSuite) TestGetCollectionByPDHErrorBadHash(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/collections/99999999999999999999999999999999+99", nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	defer resp.Body.Close()
 
 	c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
@@ -514,7 +549,7 @@ func (s *FederationSuite) TestSaltedTokenGetCollectionByPDH(c *check.C) {
 
 	req := httptest.NewRequest("GET", "/arvados/v1/collections/"+arvadostest.UserAgreementPDH, nil)
 	req.Header.Set("Authorization", "Bearer v2/zzzzz-gj3su-077z32aux8dg2s1/282d7d172b6cfdce364c5ed12ddf7417b2d00065")
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var col arvados.Collection
@@ -535,7 +570,7 @@ func (s *FederationSuite) TestSaltedTokenGetCollectionByPDHError(c *check.C) {
 
 	req := httptest.NewRequest("GET", "/arvados/v1/collections/99999999999999999999999999999999+99", nil)
 	req.Header.Set("Authorization", "Bearer v2/zzzzz-gj3su-077z32aux8dg2s1/282d7d172b6cfdce364c5ed12ddf7417b2d00065")
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 
 	c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
 }
@@ -544,7 +579,7 @@ func (s *FederationSuite) TestGetRemoteContainerRequest(c *check.C) {
 	defer s.localServiceReturns404(c).Close()
 	req := httptest.NewRequest("GET", "/arvados/v1/container_requests/"+arvadostest.QueuedContainerRequestUUID, nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var cr arvados.ContainerRequest
 	c.Check(json.NewDecoder(resp.Body).Decode(&cr), check.IsNil)
@@ -559,7 +594,7 @@ func (s *FederationSuite) TestUpdateRemoteContainerRequest(c *check.C) {
 			strings.NewReader(fmt.Sprintf(`{"container_request": {"priority": %d}}`, pri)))
 		req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 		req.Header.Set("Content-type", "application/json")
-		resp := s.testRequest(req)
+		resp := s.testRequest(req).Result()
 		c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 		var cr arvados.ContainerRequest
 		c.Check(json.NewDecoder(resp.Body).Decode(&cr), check.IsNil)
@@ -587,7 +622,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequest(c *check.C) {
 `))
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 	req.Header.Set("Content-type", "application/json")
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var cr arvados.ContainerRequest
 	c.Check(json.NewDecoder(resp.Body).Decode(&cr), check.IsNil)
@@ -624,7 +659,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequestCheckRuntimeToken(c *c
 	s.testHandler.Cluster.NodeProfiles["*"] = np
 	s.testHandler.NodeProfile = &np
 
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var cr struct {
 		arvados.ContainerRequest `json:"container_request"`
@@ -655,7 +690,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequestCheckSetRuntimeToken(c
 `))
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 	req.Header.Set("Content-type", "application/json")
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var cr struct {
 		arvados.ContainerRequest `json:"container_request"`
@@ -684,7 +719,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequestRuntimeTokenFromAuth(c
 `))
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveTokenV2+"/zzzzz-dz642-parentcontainer")
 	req.Header.Set("Content-type", "application/json")
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var cr struct {
 		arvados.ContainerRequest `json:"container_request"`
@@ -710,7 +745,7 @@ func (s *FederationSuite) TestCreateRemoteContainerRequestError(c *check.C) {
 `))
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 	req.Header.Set("Content-type", "application/json")
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusNotFound)
 }
 
@@ -719,7 +754,7 @@ func (s *FederationSuite) TestGetRemoteContainer(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/containers/"+arvadostest.QueuedContainerUUID, nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
 	resp := s.testRequest(req)
-	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
+	c.Check(resp.Code, check.Equals, http.StatusOK)
 	var cn arvados.Container
 	c.Check(json.NewDecoder(resp.Body).Decode(&cn), check.IsNil)
 	c.Check(cn.UUID, check.Equals, arvadostest.QueuedContainerUUID)
@@ -730,10 +765,11 @@ func (s *FederationSuite) TestListRemoteContainer(c *check.C) {
 	req := httptest.NewRequest("GET", "/arvados/v1/containers?count=none&filters="+
 		url.QueryEscape(fmt.Sprintf(`[["uuid", "in", ["%v"]]]`, arvadostest.QueuedContainerUUID)), nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var cn arvados.ContainerList
 	c.Check(json.NewDecoder(resp.Body).Decode(&cn), check.IsNil)
+	c.Assert(cn.Items, check.HasLen, 1)
 	c.Check(cn.Items[0].UUID, check.Equals, arvadostest.QueuedContainerUUID)
 }
 
@@ -750,7 +786,7 @@ func (s *FederationSuite) TestListMultiRemoteContainers(c *check.C) {
 		url.QueryEscape(`["uuid", "command"]`)),
 		nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	var cn arvados.ContainerList
 	c.Check(json.NewDecoder(resp.Body).Decode(&cn), check.IsNil)
@@ -773,7 +809,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerError(c *check.C) {
 		url.QueryEscape(`["uuid", "command"]`)),
 		nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusBadGateway)
 	s.checkJSONErrorMatches(c, resp, `error fetching from zhome \(404 Not Found\): EOF`)
 }
@@ -799,7 +835,7 @@ func (s *FederationSuite) TestListMultiRemoteContainersPaged(c *check.C) {
 			arvadostest.QueuedContainerUUID))),
 		nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	c.Check(callCount, check.Equals, 2)
 	var cn arvados.ContainerList
@@ -835,7 +871,7 @@ func (s *FederationSuite) TestListMultiRemoteContainersMissing(c *check.C) {
 			arvadostest.QueuedContainerUUID))),
 		nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 	c.Check(callCount, check.Equals, 2)
 	var cn arvados.ContainerList
@@ -856,7 +892,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerPageSizeError(c *check.C)
 			arvadostest.QueuedContainerUUID))),
 		nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
 	s.checkJSONErrorMatches(c, resp, `Federated multi-object request for 2 objects which is more than max page size 1.`)
 }
@@ -867,7 +903,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerLimitError(c *check.C) {
 			arvadostest.QueuedContainerUUID))),
 		nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
 	s.checkJSONErrorMatches(c, resp, `Federated multi-object may not provide 'limit', 'offset' or 'order'.`)
 }
@@ -878,7 +914,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerOffsetError(c *check.C) {
 			arvadostest.QueuedContainerUUID))),
 		nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
 	s.checkJSONErrorMatches(c, resp, `Federated multi-object may not provide 'limit', 'offset' or 'order'.`)
 }
@@ -889,7 +925,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerOrderError(c *check.C) {
 			arvadostest.QueuedContainerUUID))),
 		nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
 	s.checkJSONErrorMatches(c, resp, `Federated multi-object may not provide 'limit', 'offset' or 'order'.`)
 }
@@ -901,7 +937,7 @@ func (s *FederationSuite) TestListMultiRemoteContainerSelectError(c *check.C) {
 		url.QueryEscape(`["command"]`)),
 		nil)
 	req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
-	resp := s.testRequest(req)
+	resp := s.testRequest(req).Result()
 	c.Check(resp.StatusCode, check.Equals, http.StatusBadRequest)
 	s.checkJSONErrorMatches(c, resp, `Federated multi-object request must include 'uuid' in 'select'`)
 }
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index 53125ae55..c799b617f 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -8,13 +8,14 @@ import (
 	"context"
 	"database/sql"
 	"errors"
-	"net"
 	"net/http"
 	"net/url"
 	"strings"
 	"sync"
 	"time"
 
+	"git.curoverse.com/arvados.git/lib/controller/railsproxy"
+	"git.curoverse.com/arvados.git/lib/controller/router"
 	"git.curoverse.com/arvados.git/sdk/go/arvados"
 	"git.curoverse.com/arvados.git/sdk/go/health"
 	"git.curoverse.com/arvados.git/sdk/go/httpserver"
@@ -61,7 +62,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 
 func (h *Handler) CheckHealth() error {
 	h.setupOnce.Do(h.setup)
-	_, _, err := findRailsAPI(h.Cluster, h.NodeProfile)
+	_, _, err := railsproxy.FindRailsAPI(h.Cluster, h.NodeProfile)
 	return err
 }
 
@@ -73,6 +74,13 @@ func (h *Handler) setup() {
 		Token:  h.Cluster.ManagementToken,
 		Prefix: "/_health/",
 	})
+
+	if h.Cluster.EnableBetaController14287 {
+		rtr := router.New(h.Cluster, h.NodeProfile)
+		mux.Handle("/arvados/v1/collections", rtr)
+		mux.Handle("/arvados/v1/collections/", rtr)
+	}
+
 	hs := http.NotFoundHandler()
 	hs = prepend(hs, h.proxyRailsAPI)
 	hs = h.setupProxyRemoteCluster(hs)
@@ -126,7 +134,7 @@ func prepend(next http.Handler, middleware middlewareFunc) http.Handler {
 }
 
 func (h *Handler) localClusterRequest(req *http.Request) (*http.Response, error) {
-	urlOut, insecure, err := findRailsAPI(h.Cluster, h.NodeProfile)
+	urlOut, insecure, err := railsproxy.FindRailsAPI(h.Cluster, h.NodeProfile)
 	if err != nil {
 		return nil, err
 	}
@@ -151,23 +159,3 @@ func (h *Handler) proxyRailsAPI(w http.ResponseWriter, req *http.Request, next h
 		httpserver.Logger(req).WithError(err).WithField("bytesCopied", n).Error("error copying response body")
 	}
 }
-
-// For now, findRailsAPI always uses the rails API running on this
-// node.
-func findRailsAPI(cluster *arvados.Cluster, np *arvados.NodeProfile) (*url.URL, bool, error) {
-	hostport := np.RailsAPI.Listen
-	if len(hostport) > 1 && hostport[0] == ':' && strings.TrimRight(hostport[1:], "0123456789") == "" {
-		// ":12345" => connect to indicated port on localhost
-		hostport = "localhost" + hostport
-	} else if _, _, err := net.SplitHostPort(hostport); err == nil {
-		// "[::1]:12345" => connect to indicated address & port
-	} else {
-		return nil, false, err
-	}
-	proto := "http"
-	if np.RailsAPI.TLS {
-		proto = "https"
-	}
-	url, err := url.Parse(proto + "://" + hostport)
-	return url, np.RailsAPI.Insecure, err
-}
diff --git a/lib/controller/handler_test.go b/lib/controller/handler_test.go
index 96110ea85..7041d3504 100644
--- a/lib/controller/handler_test.go
+++ b/lib/controller/handler_test.go
@@ -22,9 +22,13 @@ import (
 	check "gopkg.in/check.v1"
 )
 
+var enableBetaController14287 bool
+
 // Gocheck boilerplate
 func Test(t *testing.T) {
-	check.TestingT(t)
+	for _, enableBetaController14287 = range []bool{false, true} {
+		check.TestingT(t)
+	}
 }
 
 var _ = check.Suite(&HandlerSuite{})
@@ -48,6 +52,7 @@ func (s *HandlerSuite) SetUpTest(c *check.C) {
 				RailsAPI:   arvados.SystemServiceInstance{Listen: os.Getenv("ARVADOS_TEST_API_HOST"), TLS: true, Insecure: true},
 			},
 		},
+		EnableBetaController14287: enableBetaController14287,
 	}
 	node := s.cluster.NodeProfiles["*"]
 	s.handler = newHandler(s.ctx, s.cluster, &node, "")
diff --git a/lib/controller/proxy.go b/lib/controller/proxy.go
index c0b94c2b5..9eac9362c 100644
--- a/lib/controller/proxy.go
+++ b/lib/controller/proxy.go
@@ -25,20 +25,23 @@ func (h HTTPError) Error() string {
 	return h.Message
 }
 
-// headers that shouldn't be forwarded when proxying. See
-// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
 var dropHeaders = map[string]bool{
+	// Headers that shouldn't be forwarded when proxying. See
+	// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
 	"Connection":          true,
 	"Keep-Alive":          true,
 	"Proxy-Authenticate":  true,
 	"Proxy-Authorization": true,
-	// this line makes gofmt 1.10 and 1.11 agree
-	"TE":                true,
-	"Trailer":           true,
-	"Transfer-Encoding": true, // *-Encoding headers interfer with Go's automatic compression/decompression
-	"Content-Encoding":  true,
+	// (comment/space here makes gofmt1.10 agree with gofmt1.11)
+	"TE":      true,
+	"Trailer": true,
+	"Upgrade": true,
+
+	// Headers that would interfere with Go's automatic
+	// compression/decompression if we forwarded them.
 	"Accept-Encoding":   true,
-	"Upgrade":           true,
+	"Content-Encoding":  true,
+	"Transfer-Encoding": true,
 }
 
 type ResponseFilter func(*http.Response, error) (*http.Response, error)
diff --git a/lib/controller/railsproxy/railsproxy.go b/lib/controller/railsproxy/railsproxy.go
new file mode 100644
index 000000000..db1a3f5e6
--- /dev/null
+++ b/lib/controller/railsproxy/railsproxy.go
@@ -0,0 +1,56 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Package railsproxy implements Arvados APIs by proxying to the
+// RailsAPI server on the local machine.
+package railsproxy
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"net/url"
+	"strings"
+
+	"git.curoverse.com/arvados.git/lib/controller/rpc"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/auth"
+)
+
+// For now, FindRailsAPI always uses the rails API running on this
+// node.
+func FindRailsAPI(cluster *arvados.Cluster, np *arvados.NodeProfile) (*url.URL, bool, error) {
+	hostport := np.RailsAPI.Listen
+	if len(hostport) > 1 && hostport[0] == ':' && strings.TrimRight(hostport[1:], "0123456789") == "" {
+		// ":12345" => connect to indicated port on localhost
+		hostport = "localhost" + hostport
+	} else if _, _, err := net.SplitHostPort(hostport); err == nil {
+		// "[::1]:12345" => connect to indicated address & port
+	} else {
+		return nil, false, err
+	}
+	proto := "http"
+	if np.RailsAPI.TLS {
+		proto = "https"
+	}
+	url, err := url.Parse(proto + "://" + hostport)
+	return url, np.RailsAPI.Insecure, err
+}
+
+func NewConn(cluster *arvados.Cluster, np *arvados.NodeProfile) *rpc.Conn {
+	url, insecure, err := FindRailsAPI(cluster, np)
+	if err != nil {
+		panic(fmt.Sprintf("NodeProfile RailsAPI %#v: %s", np.RailsAPI, err))
+	}
+	return rpc.NewConn(cluster.ClusterID, url, insecure, provideIncomingToken)
+}
+
+func provideIncomingToken(ctx context.Context) ([]string, error) {
+	incoming, ok := ctx.Value(auth.ContextKeyCredentials).(*auth.Credentials)
+	if !ok {
+		return nil, errors.New("no token provided")
+	}
+	return incoming.Tokens, nil
+}
diff --git a/lib/controller/router/error.go b/lib/controller/router/error.go
new file mode 100644
index 000000000..6db5f3155
--- /dev/null
+++ b/lib/controller/router/error.go
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+type errorWithStatus struct {
+	code int
+	error
+}
+
+func (err errorWithStatus) HTTPStatus() int {
+	return err.code
+}
+
+func httpError(code int, err error) error {
+	return errorWithStatus{code: code, error: err}
+}
diff --git a/lib/controller/router/request.go b/lib/controller/router/request.go
new file mode 100644
index 000000000..67d4e0ffb
--- /dev/null
+++ b/lib/controller/router/request.go
@@ -0,0 +1,112 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+	"encoding/json"
+	"io"
+	"mime"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/julienschmidt/httprouter"
+)
+
+// Parse req as an Arvados V1 API request and return the request
+// parameters.
+//
+// If the request has a parameter whose name is attrsKey (e.g.,
+// "collection"), it is renamed to "attrs".
+func (rtr *router) loadRequestParams(req *http.Request, attrsKey string) (map[string]interface{}, error) {
+	err := req.ParseForm()
+	if err != nil {
+		return nil, httpError(http.StatusBadRequest, err)
+	}
+	params := map[string]interface{}{}
+	for k, values := range req.Form {
+		for _, v := range values {
+			switch {
+			case v == "null" || v == "":
+				params[k] = nil
+			case strings.HasPrefix(v, "["):
+				var j []interface{}
+				err := json.Unmarshal([]byte(v), &j)
+				if err != nil {
+					return nil, err
+				}
+				params[k] = j
+			case strings.HasPrefix(v, "{"):
+				var j map[string]interface{}
+				err := json.Unmarshal([]byte(v), &j)
+				if err != nil {
+					return nil, err
+				}
+				params[k] = j
+			case strings.HasPrefix(v, "\""):
+				var j string
+				err := json.Unmarshal([]byte(v), &j)
+				if err != nil {
+					return nil, err
+				}
+				params[k] = j
+			case k == "limit" || k == "offset":
+				params[k], err = strconv.ParseInt(v, 10, 64)
+				if err != nil {
+					return nil, err
+				}
+			default:
+				params[k] = v
+			}
+			// TODO: Need to accept "?foo[]=bar&foo[]=baz"
+			// as foo=["bar","baz"]?
+		}
+	}
+	if ct, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")); err != nil && ct == "application/json" {
+		jsonParams := map[string]interface{}{}
+		err := json.NewDecoder(req.Body).Decode(jsonParams)
+		if err != nil {
+			return nil, httpError(http.StatusBadRequest, err)
+		}
+		for k, v := range jsonParams {
+			params[k] = v
+		}
+		if attrsKey != "" && params[attrsKey] == nil {
+			// Copy top-level parameters from JSON request
+			// body into params[attrsKey]. Some SDKs rely
+			// on this Rails API feature; see
+			// https://api.rubyonrails.org/v5.2.1/classes/ActionController/ParamsWrapper.html
+			params[attrsKey] = jsonParams
+		}
+	}
+
+	routeParams, _ := req.Context().Value(httprouter.ParamsKey).(httprouter.Params)
+	for _, p := range routeParams {
+		params[p.Key] = p.Value
+	}
+
+	if v, ok := params[attrsKey]; ok && attrsKey != "" {
+		params["attrs"] = v
+		delete(params, attrsKey)
+	}
+	return params, nil
+}
+
+// Copy src to dst, using json as an intermediate format in order to
+// invoke src's json-marshaling and dst's json-unmarshaling behaviors.
+func (rtr *router) transcode(src interface{}, dst interface{}) error {
+	var errw error
+	pr, pw := io.Pipe()
+	go func() {
+		defer pw.Close()
+		errw = json.NewEncoder(pw).Encode(src)
+	}()
+	defer pr.Close()
+	err := json.NewDecoder(pr).Decode(dst)
+	if errw != nil {
+		return errw
+	}
+	return err
+}
diff --git a/lib/controller/router/response.go b/lib/controller/router/response.go
new file mode 100644
index 000000000..65e0159fa
--- /dev/null
+++ b/lib/controller/router/response.go
@@ -0,0 +1,53 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/httpserver"
+)
+
+type responseOptions struct {
+	Select []string
+}
+
+func (rtr *router) responseOptions(opts interface{}) (responseOptions, error) {
+	var rOpts responseOptions
+	switch opts := opts.(type) {
+	case *arvados.GetOptions:
+		rOpts.Select = opts.Select
+	}
+	return rOpts, nil
+}
+
+func (rtr *router) sendResponse(w http.ResponseWriter, resp interface{}, opts responseOptions) {
+	var tmp map[string]interface{}
+	err := rtr.transcode(resp, &tmp)
+	if err != nil {
+		rtr.sendError(w, err)
+		return
+	}
+	if len(opts.Select) > 0 {
+		selected := map[string]interface{}{}
+		for _, attr := range opts.Select {
+			if v, ok := tmp[attr]; ok {
+				selected[attr] = v
+			}
+		}
+		tmp = selected
+	}
+	json.NewEncoder(w).Encode(tmp)
+}
+
+func (rtr *router) sendError(w http.ResponseWriter, err error) {
+	code := http.StatusInternalServerError
+	if err, ok := err.(interface{ HTTPStatus() int }); ok {
+		code = err.HTTPStatus()
+	}
+	httpserver.Error(w, err.Error(), code)
+}
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
new file mode 100644
index 000000000..4a6f9b5af
--- /dev/null
+++ b/lib/controller/router/router.go
@@ -0,0 +1,210 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+	"context"
+	"net/http"
+
+	"git.curoverse.com/arvados.git/lib/controller/federation"
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/auth"
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
+	"github.com/julienschmidt/httprouter"
+)
+
+type router struct {
+	mux *httprouter.Router
+	fed federation.Interface
+}
+
+func New(cluster *arvados.Cluster, np *arvados.NodeProfile) *router {
+	rtr := &router{
+		mux: httprouter.New(),
+		fed: federation.New(cluster, np),
+	}
+	rtr.addRoutes(cluster)
+	return rtr
+}
+
+func (rtr *router) addRoutes(cluster *arvados.Cluster) {
+	for _, route := range []struct {
+		endpoint    arvados.APIEndpoint
+		defaultOpts func() interface{}
+		exec        func(ctx context.Context, opts interface{}) (interface{}, error)
+	}{
+		{
+			arvados.EndpointCollectionCreate,
+			func() interface{} { return &arvados.CreateOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.CollectionCreate(ctx, *opts.(*arvados.CreateOptions))
+			},
+		},
+		{
+			arvados.EndpointCollectionUpdate,
+			func() interface{} { return &arvados.UpdateOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.CollectionUpdate(ctx, *opts.(*arvados.UpdateOptions))
+			},
+		},
+		{
+			arvados.EndpointCollectionGet,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.CollectionGet(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointCollectionList,
+			func() interface{} { return &arvados.ListOptions{Limit: -1} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.CollectionList(ctx, *opts.(*arvados.ListOptions))
+			},
+		},
+		{
+			arvados.EndpointCollectionDelete,
+			func() interface{} { return &arvados.DeleteOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.CollectionDelete(ctx, *opts.(*arvados.DeleteOptions))
+			},
+		},
+		{
+			arvados.EndpointContainerCreate,
+			func() interface{} { return &arvados.CreateOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.ContainerCreate(ctx, *opts.(*arvados.CreateOptions))
+			},
+		},
+		{
+			arvados.EndpointContainerUpdate,
+			func() interface{} { return &arvados.UpdateOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.ContainerUpdate(ctx, *opts.(*arvados.UpdateOptions))
+			},
+		},
+		{
+			arvados.EndpointContainerGet,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.ContainerGet(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointContainerList,
+			func() interface{} { return &arvados.ListOptions{Limit: -1} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.ContainerList(ctx, *opts.(*arvados.ListOptions))
+			},
+		},
+		{
+			arvados.EndpointContainerDelete,
+			func() interface{} { return &arvados.DeleteOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
+			},
+		},
+		{
+			arvados.EndpointContainerLock,
+			func() interface{} {
+				return &arvados.GetOptions{Select: []string{"uuid", "state", "priority", "auth_uuid", "locked_by_uuid"}}
+			},
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.ContainerLock(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointContainerUnlock,
+			func() interface{} {
+				return &arvados.GetOptions{Select: []string{"uuid", "state", "priority", "auth_uuid", "locked_by_uuid"}}
+			},
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.ContainerUnlock(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointSpecimenCreate,
+			func() interface{} { return &arvados.CreateOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.SpecimenCreate(ctx, *opts.(*arvados.CreateOptions))
+			},
+		},
+		{
+			arvados.EndpointSpecimenUpdate,
+			func() interface{} { return &arvados.UpdateOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.SpecimenUpdate(ctx, *opts.(*arvados.UpdateOptions))
+			},
+		},
+		{
+			arvados.EndpointSpecimenGet,
+			func() interface{} { return &arvados.GetOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.SpecimenGet(ctx, *opts.(*arvados.GetOptions))
+			},
+		},
+		{
+			arvados.EndpointSpecimenList,
+			func() interface{} { return &arvados.ListOptions{Limit: -1} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.SpecimenList(ctx, *opts.(*arvados.ListOptions))
+			},
+		},
+		{
+			arvados.EndpointSpecimenDelete,
+			func() interface{} { return &arvados.DeleteOptions{} },
+			func(ctx context.Context, opts interface{}) (interface{}, error) {
+				return rtr.fed.SpecimenDelete(ctx, *opts.(*arvados.DeleteOptions))
+			},
+		},
+	} {
+		route := route
+		methods := []string{route.endpoint.Method}
+		if route.endpoint.Method == "PATCH" {
+			methods = append(methods, "PUT")
+		}
+		for _, method := range methods {
+			rtr.mux.HandlerFunc(method, "/"+route.endpoint.Path, func(w http.ResponseWriter, req *http.Request) {
+				params, err := rtr.loadRequestParams(req, route.endpoint.AttrsKey)
+				if err != nil {
+					rtr.sendError(w, err)
+					return
+				}
+				opts := route.defaultOpts()
+				err = rtr.transcode(params, opts)
+				if err != nil {
+					rtr.sendError(w, err)
+					return
+				}
+				respOpts, err := rtr.responseOptions(opts)
+				if err != nil {
+					rtr.sendError(w, err)
+					return
+				}
+
+				creds := auth.CredentialsFromRequest(req)
+				ctx := req.Context()
+				ctx = context.WithValue(ctx, auth.ContextKeyCredentials, creds)
+				ctx = arvados.ContextWithRequestID(ctx, req.Header.Get("X-Request-Id"))
+				resp, err := route.exec(ctx, opts)
+				if err != nil {
+					ctxlog.FromContext(ctx).WithError(err).Infof("returning error response for %#v", err)
+					rtr.sendError(w, err)
+					return
+				}
+				rtr.sendResponse(w, resp, respOpts)
+			})
+		}
+	}
+}
+
+func (rtr *router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	r.ParseForm()
+	if m := r.FormValue("_method"); m != "" {
+		r2 := *r
+		r = &r2
+		r.Method = m
+	}
+	rtr.mux.ServeHTTP(w, r)
+}
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
new file mode 100644
index 000000000..97710d265
--- /dev/null
+++ b/lib/controller/router/router_test.go
@@ -0,0 +1,127 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package router
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
+	check "gopkg.in/check.v1"
+)
+
+// Gocheck boilerplate
+func Test(t *testing.T) {
+	check.TestingT(t)
+}
+
+var _ = check.Suite(&RouterSuite{})
+
+type RouterSuite struct {
+	rtr *router
+}
+
+func (s *RouterSuite) SetUpTest(c *check.C) {
+	s.rtr = New(&arvados.Cluster{}, &arvados.NodeProfile{RailsAPI: arvados.SystemServiceInstance{Listen: os.Getenv("ARVADOS_TEST_API_HOST"), TLS: true, Insecure: true}})
+}
+
+func (s *RouterSuite) TearDownTest(c *check.C) {
+	err := arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil)
+	c.Check(err, check.IsNil)
+}
+
+func (s *RouterSuite) doRequest(c *check.C, token, method, path string, hdrs http.Header, body io.Reader) (*http.Request, *httptest.ResponseRecorder, map[string]interface{}) {
+	req := httptest.NewRequest(method, path, body)
+	for k, v := range hdrs {
+		req.Header[k] = v
+	}
+	req.Header.Set("Authorization", "Bearer "+token)
+	rw := httptest.NewRecorder()
+	s.rtr.ServeHTTP(rw, req)
+	c.Logf("response body: %s", rw.Body.String())
+	var jresp map[string]interface{}
+	err := json.Unmarshal(rw.Body.Bytes(), &jresp)
+	c.Check(err, check.IsNil)
+	return req, rw, jresp
+}
+
+func (s *RouterSuite) TestContainerList(c *check.C) {
+	token := arvadostest.ActiveTokenV2
+
+	_, rw, jresp := s.doRequest(c, token, "GET", `/arvados/v1/containers?limit=0`, nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+	c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
+	c.Check(jresp["items"], check.HasLen, 0)
+
+	_, rw, jresp = s.doRequest(c, token, "GET", `/arvados/v1/containers?limit=2&select=["uuid","command"]`, nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+	c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
+	c.Check(jresp["items"], check.HasLen, 2)
+	item0 := jresp["items"].([]interface{})[0].(map[string]interface{})
+	c.Check(item0["uuid"], check.HasLen, 27)
+	c.Check(item0["command"], check.FitsTypeOf, []interface{}{})
+	c.Check(item0["command"].([]interface{})[0], check.FitsTypeOf, "")
+	c.Check(item0["mounts"], check.IsNil)
+
+	_, rw, jresp = s.doRequest(c, token, "GET", `/arvados/v1/containers`, nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["items_available"], check.FitsTypeOf, float64(0))
+	c.Check(jresp["items_available"].(float64) > 2, check.Equals, true)
+	avail := int(jresp["items_available"].(float64))
+	c.Check(jresp["items"], check.HasLen, avail)
+	item0 = jresp["items"].([]interface{})[0].(map[string]interface{})
+	c.Check(item0["uuid"], check.HasLen, 27)
+	c.Check(item0["command"], check.FitsTypeOf, []interface{}{})
+	c.Check(item0["command"].([]interface{})[0], check.FitsTypeOf, "")
+	c.Check(item0["mounts"], check.NotNil)
+}
+
+func (s *RouterSuite) TestContainerLock(c *check.C) {
+	uuid := arvadostest.QueuedContainerUUID
+	token := arvadostest.ActiveTokenV2
+	_, rw, jresp := s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["uuid"], check.HasLen, 27)
+	c.Check(jresp["state"], check.Equals, "Locked")
+	_, rw, jresp = s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/lock", nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusUnprocessableEntity)
+	c.Check(rw.Body.String(), check.Not(check.Matches), `.*"uuid":.*`)
+	_, rw, jresp = s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusOK)
+	c.Check(jresp["uuid"], check.HasLen, 27)
+	c.Check(jresp["state"], check.Equals, "Queued")
+	c.Check(jresp["environment"], check.IsNil)
+	_, rw, jresp = s.doRequest(c, token, "POST", "/arvados/v1/containers/"+uuid+"/unlock", nil, nil)
+	c.Check(rw.Code, check.Equals, http.StatusUnprocessableEntity)
+	c.Check(jresp["uuid"], check.IsNil)
+}
+
+func (s *RouterSuite) TestSelectParam(c *check.C) {
+	uuid := arvadostest.QueuedContainerUUID
+	token := arvadostest.ActiveTokenV2
+	for _, sel := range [][]string{
+		{"uuid", "command"},
+		{"uuid", "command", "uuid"},
+		{"", "command", "uuid"},
+	} {
+		j, err := json.Marshal(sel)
+		c.Assert(err, check.IsNil)
+		_, rw, resp := s.doRequest(c, token, "GET", "/arvados/v1/containers/"+uuid+"?select="+string(j), nil, nil)
+		c.Check(rw.Code, check.Equals, http.StatusOK)
+
+		c.Check(resp["uuid"], check.HasLen, 27)
+		c.Check(resp["command"], check.HasLen, 2)
+		c.Check(resp["mounts"], check.IsNil)
+		_, hasMounts := resp["mounts"]
+		c.Check(hasMounts, check.Equals, false)
+	}
+}
diff --git a/lib/controller/rpc/conn.go b/lib/controller/rpc/conn.go
new file mode 100644
index 000000000..7c23ed170
--- /dev/null
+++ b/lib/controller/rpc/conn.go
@@ -0,0 +1,234 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package rpc
+
+import (
+	"context"
+	"crypto/tls"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+)
+
+type contextKey string
+
+const ContextKeyCredentials contextKey = "credentials"
+
+type TokenProvider func(context.Context) ([]string, error)
+
+type Conn struct {
+	clusterID     string
+	httpClient    http.Client
+	baseURL       url.URL
+	tokenProvider TokenProvider
+}
+
+func NewConn(clusterID string, url *url.URL, insecure bool, tp TokenProvider) *Conn {
+	transport := http.DefaultTransport
+	if insecure {
+		// It's not safe to copy *http.DefaultTransport
+		// because it has a mutex (which might be locked)
+		// protecting a private map (which might not be nil).
+		// So we build our own, using the Go 1.12 default
+		// values, ignoring any changes the application has
+		// made to http.DefaultTransport.
+		transport = &http.Transport{
+			DialContext: (&net.Dialer{
+				Timeout:   30 * time.Second,
+				KeepAlive: 30 * time.Second,
+				DualStack: true,
+			}).DialContext,
+			MaxIdleConns:          100,
+			IdleConnTimeout:       90 * time.Second,
+			TLSHandshakeTimeout:   10 * time.Second,
+			ExpectContinueTimeout: 1 * time.Second,
+			TLSClientConfig:       &tls.Config{InsecureSkipVerify: true},
+		}
+	}
+	return &Conn{
+		clusterID:     clusterID,
+		httpClient:    http.Client{Transport: transport},
+		baseURL:       *url,
+		tokenProvider: tp,
+	}
+}
+
+func (conn *Conn) requestAndDecode(ctx context.Context, dst interface{}, ep arvados.APIEndpoint, body io.Reader, opts interface{}) error {
+	aClient := arvados.Client{
+		Client:  &conn.httpClient,
+		Scheme:  conn.baseURL.Scheme,
+		APIHost: conn.baseURL.Host,
+	}
+	tokens, err := conn.tokenProvider(ctx)
+	if err != nil {
+		return err
+	} else if len(tokens) == 0 {
+		return fmt.Errorf("bug: token provider returned no tokens and no error")
+	}
+	ctx = context.WithValue(ctx, "Authorization", "Bearer "+tokens[0])
+
+	// Encode opts to JSON and decode from there to a
+	// map[string]interface{}, so we can munge the query params
+	// using the JSON key names specified by opts' struct tags.
+	j, err := json.Marshal(opts)
+	if err != nil {
+		return fmt.Errorf("%T: requestAndDecode: Marshal opts: %s", conn, err)
+	}
+	var params map[string]interface{}
+	err = json.Unmarshal(j, &params)
+	if err != nil {
+		return fmt.Errorf("%T: requestAndDecode: Unmarshal opts: %s", conn, err)
+	}
+	if attrs, ok := params["attrs"]; ok && ep.AttrsKey != "" {
+		params[ep.AttrsKey] = attrs
+		delete(params, "attrs")
+	}
+	if limit, ok := params["limit"].(float64); ok && limit < 0 {
+		// Negative limit means "not specified" here, but some
+		// servers/versions do not accept that, so we need to
+		// remove it entirely.
+		delete(params, "limit")
+	}
+	path := ep.Path
+	if strings.Contains(ep.Path, "/:uuid") {
+		uuid, _ := params["uuid"].(string)
+		path = strings.Replace(path, "/:uuid", "/"+uuid, 1)
+		delete(params, "uuid")
+	}
+	return aClient.RequestAndDecodeContext(ctx, dst, ep.Method, path, body, params)
+}
+
+func (conn *Conn) CollectionCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Collection, error) {
+	ep := arvados.EndpointCollectionCreate
+	var resp arvados.Collection
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) CollectionUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Collection, error) {
+	ep := arvados.EndpointCollectionUpdate
+	var resp arvados.Collection
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) CollectionGet(ctx context.Context, options arvados.GetOptions) (arvados.Collection, error) {
+	ep := arvados.EndpointCollectionGet
+	var resp arvados.Collection
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) CollectionList(ctx context.Context, options arvados.ListOptions) (arvados.CollectionList, error) {
+	ep := arvados.EndpointCollectionList
+	var resp arvados.CollectionList
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) CollectionDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Collection, error) {
+	ep := arvados.EndpointCollectionDelete
+	var resp arvados.Collection
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) ContainerCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Container, error) {
+	ep := arvados.EndpointContainerCreate
+	var resp arvados.Container
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) ContainerUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Container, error) {
+	ep := arvados.EndpointContainerUpdate
+	var resp arvados.Container
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) ContainerGet(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+	ep := arvados.EndpointContainerGet
+	var resp arvados.Container
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) ContainerList(ctx context.Context, options arvados.ListOptions) (arvados.ContainerList, error) {
+	ep := arvados.EndpointContainerList
+	var resp arvados.ContainerList
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) ContainerDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Container, error) {
+	ep := arvados.EndpointContainerDelete
+	var resp arvados.Container
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) ContainerLock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+	ep := arvados.EndpointContainerLock
+	var resp arvados.Container
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) ContainerUnlock(ctx context.Context, options arvados.GetOptions) (arvados.Container, error) {
+	ep := arvados.EndpointContainerUnlock
+	var resp arvados.Container
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) SpecimenCreate(ctx context.Context, options arvados.CreateOptions) (arvados.Specimen, error) {
+	ep := arvados.EndpointSpecimenCreate
+	var resp arvados.Specimen
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) SpecimenUpdate(ctx context.Context, options arvados.UpdateOptions) (arvados.Specimen, error) {
+	ep := arvados.EndpointSpecimenUpdate
+	var resp arvados.Specimen
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) SpecimenGet(ctx context.Context, options arvados.GetOptions) (arvados.Specimen, error) {
+	ep := arvados.EndpointSpecimenGet
+	var resp arvados.Specimen
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) SpecimenList(ctx context.Context, options arvados.ListOptions) (arvados.SpecimenList, error) {
+	ep := arvados.EndpointSpecimenList
+	var resp arvados.SpecimenList
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) SpecimenDelete(ctx context.Context, options arvados.DeleteOptions) (arvados.Specimen, error) {
+	ep := arvados.EndpointSpecimenDelete
+	var resp arvados.Specimen
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
+
+func (conn *Conn) APIClientAuthorizationCurrent(ctx context.Context, options arvados.GetOptions) (arvados.APIClientAuthorization, error) {
+	ep := arvados.EndpointAPIClientAuthorizationCurrent
+	var resp arvados.APIClientAuthorization
+	err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+	return resp, err
+}
diff --git a/lib/controller/rpc/conn_test.go b/lib/controller/rpc/conn_test.go
new file mode 100644
index 000000000..80e90a043
--- /dev/null
+++ b/lib/controller/rpc/conn_test.go
@@ -0,0 +1,79 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package rpc
+
+import (
+	"context"
+	"net/url"
+	"os"
+	"testing"
+
+	"git.curoverse.com/arvados.git/sdk/go/arvados"
+	"git.curoverse.com/arvados.git/sdk/go/arvadostest"
+	"git.curoverse.com/arvados.git/sdk/go/ctxlog"
+	"github.com/sirupsen/logrus"
+	check "gopkg.in/check.v1"
+)
+
+// Gocheck boilerplate
+func Test(t *testing.T) {
+	check.TestingT(t)
+}
+
+var _ = check.Suite(&RPCSuite{})
+
+const contextKeyTestTokens = "testTokens"
+
+type RPCSuite struct {
+	log  logrus.FieldLogger
+	ctx  context.Context
+	conn *Conn
+}
+
+func (s *RPCSuite) SetUpTest(c *check.C) {
+	ctx := ctxlog.Context(context.Background(), ctxlog.TestLogger(c))
+	s.ctx = context.WithValue(ctx, contextKeyTestTokens, []string{arvadostest.ActiveToken})
+	s.conn = NewConn("zzzzz", &url.URL{Scheme: "https", Host: os.Getenv("ARVADOS_TEST_API_HOST")}, true, func(ctx context.Context) ([]string, error) {
+		return ctx.Value(contextKeyTestTokens).([]string), nil
+	})
+}
+
+func (s *RPCSuite) TestCollectionCreate(c *check.C) {
+	coll, err := s.conn.CollectionCreate(s.ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
+		"owner_uuid":         arvadostest.ActiveUserUUID,
+		"portable_data_hash": "d41d8cd98f00b204e9800998ecf8427e+0",
+	}})
+	c.Check(err, check.IsNil)
+	c.Check(coll.UUID, check.HasLen, 27)
+}
+
+func (s *RPCSuite) TestSpecimenCRUD(c *check.C) {
+	sp, err := s.conn.SpecimenCreate(s.ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
+		"owner_uuid": arvadostest.ActiveUserUUID,
+		"properties": map[string]string{"foo": "bar"},
+	}})
+	c.Check(err, check.IsNil)
+	c.Check(sp.UUID, check.HasLen, 27)
+	c.Check(sp.Properties, check.HasLen, 1)
+	c.Check(sp.Properties["foo"], check.Equals, "bar")
+
+	spGet, err := s.conn.SpecimenGet(s.ctx, arvados.GetOptions{UUID: sp.UUID})
+	c.Check(spGet.UUID, check.Equals, sp.UUID)
+	c.Check(spGet.Properties["foo"], check.Equals, "bar")
+
+	spList, err := s.conn.SpecimenList(s.ctx, arvados.ListOptions{Limit: -1, Filters: []arvados.Filter{{"uuid", "=", sp.UUID}}})
+	c.Check(spList.ItemsAvailable, check.Equals, 1)
+	c.Assert(spList.Items, check.HasLen, 1)
+	c.Check(spList.Items[0].UUID, check.Equals, sp.UUID)
+	c.Check(spList.Items[0].Properties["foo"], check.Equals, "bar")
+
+	anonCtx := context.WithValue(context.Background(), contextKeyTestTokens, []string{arvadostest.AnonymousToken})
+	spList, err = s.conn.SpecimenList(anonCtx, arvados.ListOptions{Limit: -1, Filters: []arvados.Filter{{"uuid", "=", sp.UUID}}})
+	c.Check(spList.ItemsAvailable, check.Equals, 0)
+	c.Check(spList.Items, check.HasLen, 0)
+
+	spDel, err := s.conn.SpecimenDelete(s.ctx, arvados.DeleteOptions{UUID: sp.UUID})
+	c.Check(spDel.UUID, check.Equals, sp.UUID)
+}
diff --git a/lib/controller/server_test.go b/lib/controller/server_test.go
index ae89c3d7e..e5fd41712 100644
--- a/lib/controller/server_test.go
+++ b/lib/controller/server_test.go
@@ -42,6 +42,7 @@ func newServerFromIntegrationTestEnv(c *check.C) *httpserver.Server {
 		NodeProfiles: map[string]arvados.NodeProfile{
 			"*": nodeProfile,
 		},
+		EnableBetaController14287: enableBetaController14287,
 	}, NodeProfile: &nodeProfile}
 
 	srv := &httpserver.Server{
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
new file mode 100644
index 000000000..4cdf7c0e1
--- /dev/null
+++ b/sdk/go/arvados/api.go
@@ -0,0 +1,61 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+type APIEndpoint struct {
+	Method string
+	Path   string
+	// "new attributes" key for create/update requests
+	AttrsKey string
+}
+
+var (
+	EndpointCollectionCreate              = APIEndpoint{"POST", "arvados/v1/collections", "collection"}
+	EndpointCollectionUpdate              = APIEndpoint{"PATCH", "arvados/v1/collections/:uuid", "collection"}
+	EndpointCollectionGet                 = APIEndpoint{"GET", "arvados/v1/collections/:uuid", ""}
+	EndpointCollectionList                = APIEndpoint{"GET", "arvados/v1/collections", ""}
+	EndpointCollectionDelete              = APIEndpoint{"DELETE", "arvados/v1/collections/:uuid", ""}
+	EndpointSpecimenCreate                = APIEndpoint{"POST", "arvados/v1/specimens", "specimen"}
+	EndpointSpecimenUpdate                = APIEndpoint{"PATCH", "arvados/v1/specimens/:uuid", "specimen"}
+	EndpointSpecimenGet                   = APIEndpoint{"GET", "arvados/v1/specimens/:uuid", ""}
+	EndpointSpecimenList                  = APIEndpoint{"GET", "arvados/v1/specimens", ""}
+	EndpointSpecimenDelete                = APIEndpoint{"DELETE", "arvados/v1/specimens/:uuid", ""}
+	EndpointContainerCreate               = APIEndpoint{"POST", "arvados/v1/containers", "container"}
+	EndpointContainerUpdate               = APIEndpoint{"PATCH", "arvados/v1/containers/:uuid", "container"}
+	EndpointContainerGet                  = APIEndpoint{"GET", "arvados/v1/containers/:uuid", ""}
+	EndpointContainerList                 = APIEndpoint{"GET", "arvados/v1/containers", ""}
+	EndpointContainerDelete               = APIEndpoint{"DELETE", "arvados/v1/containers/:uuid", ""}
+	EndpointContainerLock                 = APIEndpoint{"POST", "arvados/v1/containers/:uuid/lock", ""}
+	EndpointContainerUnlock               = APIEndpoint{"POST", "arvados/v1/containers/:uuid/unlock", ""}
+	EndpointAPIClientAuthorizationCurrent = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/current", ""}
+)
+
+type GetOptions struct {
+	UUID   string   `json:"uuid"`
+	Select []string `json:"select"`
+}
+
+type ListOptions struct {
+	Select  []string `json:"select"`
+	Filters []Filter `json:"filters"`
+	Limit   int      `json:"limit"`
+	Offset  int      `json:"offset"`
+}
+
+type CreateOptions struct {
+	ClusterID        string                 `json:"cluster_id"`
+	EnsureUniqueName bool                   `json:"ensure_unique_name"`
+	Select           []string               `json:"select"`
+	Attrs            map[string]interface{} `json:"attrs"`
+}
+
+type UpdateOptions struct {
+	UUID  string                 `json:"uuid"`
+	Attrs map[string]interface{} `json:"attrs"`
+}
+
+type DeleteOptions struct {
+	UUID string `json:"uuid"`
+}
diff --git a/sdk/go/arvados/client.go b/sdk/go/arvados/client.go
index cbc2ca72f..8625e7ade 100644
--- a/sdk/go/arvados/client.go
+++ b/sdk/go/arvados/client.go
@@ -35,6 +35,9 @@ type Client struct {
 	// DefaultSecureClient or InsecureHTTPClient will be used.
 	Client *http.Client `json:"-"`
 
+	// Protocol scheme: "http", "https", or "" (https)
+	Scheme string
+
 	// Hostname (or host:port) of Arvados API server.
 	APIHost string
 
@@ -79,6 +82,7 @@ func NewClientFromConfig(cluster *Cluster) (*Client, error) {
 		return nil, fmt.Errorf("no host in config Services.Controller.ExternalURL: %v", ctrlURL)
 	}
 	return &Client{
+		Scheme:   ctrlURL.Scheme,
 		APIHost:  ctrlURL.Host,
 		Insecure: cluster.TLS.Insecure,
 	}, nil
@@ -105,6 +109,7 @@ func NewClientFromEnv() *Client {
 		insecure = true
 	}
 	return &Client{
+		Scheme:          "https",
 		APIHost:         os.Getenv("ARVADOS_API_HOST"),
 		AuthToken:       os.Getenv("ARVADOS_API_TOKEN"),
 		Insecure:        insecure,
@@ -117,12 +122,17 @@ var reqIDGen = httpserver.IDGenerator{Prefix: "req-"}
 // Do adds Authorization and X-Request-Id headers and then calls
 // (*http.Client)Do().
 func (c *Client) Do(req *http.Request) (*http.Response, error) {
-	if c.AuthToken != "" {
+	if auth, _ := req.Context().Value("Authorization").(string); auth != "" {
+		req.Header.Add("Authorization", auth)
+	} else if c.AuthToken != "" {
 		req.Header.Add("Authorization", "OAuth2 "+c.AuthToken)
 	}
 
 	if req.Header.Get("X-Request-Id") == "" {
-		reqid, _ := c.context().Value(contextKeyRequestID).(string)
+		reqid, _ := req.Context().Value(contextKeyRequestID).(string)
+		if reqid == "" {
+			reqid, _ = c.context().Value(contextKeyRequestID).(string)
+		}
 		if reqid == "" {
 			reqid = reqIDGen.Next()
 		}
@@ -203,6 +213,9 @@ func anythingToValues(params interface{}) (url.Values, error) {
 		if err != nil {
 			return nil, err
 		}
+		if string(j) == "null" {
+			continue
+		}
 		urlValues.Set(k, string(j))
 	}
 	return urlValues, nil
@@ -216,6 +229,10 @@ func anythingToValues(params interface{}) (url.Values, error) {
 //
 // path must not contain a query string.
 func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io.Reader, params interface{}) error {
+	return c.RequestAndDecodeContext(c.context(), dst, method, path, body, params)
+}
+
+func (c *Client) RequestAndDecodeContext(ctx context.Context, dst interface{}, method, path string, body io.Reader, params interface{}) error {
 	if body, ok := body.(io.Closer); ok {
 		// Ensure body is closed even if we error out early
 		defer body.Close()
@@ -243,6 +260,7 @@ func (c *Client) RequestAndDecode(dst interface{}, method, path string, body io.
 	if err != nil {
 		return err
 	}
+	req = req.WithContext(ctx)
 	req.Header.Set("Content-type", "application/x-www-form-urlencoded")
 	return c.DoAndDecode(dst, req)
 }
@@ -265,13 +283,13 @@ func (c *Client) UpdateBody(rsc resource) io.Reader {
 	return bytes.NewBufferString(v.Encode())
 }
 
-type contextKey string
-
-var contextKeyRequestID contextKey = "X-Request-Id"
-
+// WithRequestID returns a new shallow copy of c that sends the given
+// X-Request-Id value (instead of a new randomly generated one) with
+// each subsequent request that doesn't provide its own via context or
+// header.
 func (c *Client) WithRequestID(reqid string) *Client {
 	cc := *c
-	cc.ctx = context.WithValue(cc.context(), contextKeyRequestID, reqid)
+	cc.ctx = ContextWithRequestID(cc.context(), reqid)
 	return &cc
 }
 
@@ -294,7 +312,11 @@ func (c *Client) httpClient() *http.Client {
 }
 
 func (c *Client) apiURL(path string) string {
-	return "https://" + c.APIHost + "/" + path
+	scheme := c.Scheme
+	if scheme == "" {
+		scheme = "https"
+	}
+	return scheme + "://" + c.APIHost + "/" + path
 }
 
 // DiscoveryDocument is the Arvados server's description of itself.
diff --git a/sdk/go/arvados/collection.go b/sdk/go/arvados/collection.go
index 5b6130060..f374eea07 100644
--- a/sdk/go/arvados/collection.go
+++ b/sdk/go/arvados/collection.go
@@ -73,7 +73,6 @@ func (c *Collection) SizedDigests() ([]SizedDigest, error) {
 	return sds, scanner.Err()
 }
 
-// CollectionList is an arvados#collectionList resource.
 type CollectionList struct {
 	Items          []Collection `json:"items"`
 	ItemsAvailable int          `json:"items_available"`
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index 2965d5ecb..d309748f4 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -71,6 +71,8 @@ type Cluster struct {
 	RequestLimits      RequestLimits
 	Logging            Logging
 	TLS                TLS
+
+	EnableBetaController14287 bool
 }
 
 type Services struct {
diff --git a/sdk/go/arvados/context.go b/sdk/go/arvados/context.go
new file mode 100644
index 000000000..555cfc8e9
--- /dev/null
+++ b/sdk/go/arvados/context.go
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import (
+	"context"
+)
+
+type contextKey string
+
+var contextKeyRequestID contextKey = "X-Request-Id"
+
+func ContextWithRequestID(ctx context.Context, reqid string) context.Context {
+	return context.WithValue(ctx, contextKeyRequestID, reqid)
+}
diff --git a/sdk/go/arvados/error.go b/sdk/go/arvados/error.go
index 9a0485578..5329a5146 100644
--- a/sdk/go/arvados/error.go
+++ b/sdk/go/arvados/error.go
@@ -31,6 +31,10 @@ func (e TransactionError) Error() (s string) {
 	return
 }
 
+func (e TransactionError) HTTPStatus() int {
+	return e.StatusCode
+}
+
 func newTransactionError(req *http.Request, resp *http.Response, buf []byte) *TransactionError {
 	var e TransactionError
 	if json.Unmarshal(buf, &e) != nil {
diff --git a/sdk/go/arvados/resource_list.go b/sdk/go/arvados/resource_list.go
index 14ce098cf..505ba51ec 100644
--- a/sdk/go/arvados/resource_list.go
+++ b/sdk/go/arvados/resource_list.go
@@ -4,7 +4,10 @@
 
 package arvados
 
-import "encoding/json"
+import (
+	"encoding/json"
+	"fmt"
+)
 
 // ResourceListParams expresses which results are requested in a
 // list/index API.
@@ -27,7 +30,35 @@ type Filter struct {
 	Operand  interface{}
 }
 
-// MarshalJSON encodes a Filter in the form expected by the API.
+// MarshalJSON encodes a Filter to a JSON array.
 func (f *Filter) MarshalJSON() ([]byte, error) {
 	return json.Marshal([]interface{}{f.Attr, f.Operator, f.Operand})
 }
+
+// UnmarshalJSON decodes a JSON array to a Filter.
+func (f *Filter) UnmarshalJSON(data []byte) error {
+	var elements []interface{}
+	err := json.Unmarshal(data, &elements)
+	if err != nil {
+		return err
+	}
+	if len(elements) != 3 {
+		return fmt.Errorf("invalid filter %q: must have 3 elements", data)
+	}
+	attr, ok := elements[0].(string)
+	if !ok {
+		return fmt.Errorf("invalid filter attr %q", elements[0])
+	}
+	op, ok := elements[1].(string)
+	if !ok {
+		return fmt.Errorf("invalid filter operator %q", elements[1])
+	}
+	operand := elements[2]
+	switch operand.(type) {
+	case string, float64, []interface{}:
+	default:
+		return fmt.Errorf("invalid filter operand %q", elements[2])
+	}
+	*f = Filter{attr, op, operand}
+	return nil
+}
diff --git a/sdk/go/arvados/specimen.go b/sdk/go/arvados/specimen.go
new file mode 100644
index 000000000..e320ca2c3
--- /dev/null
+++ b/sdk/go/arvados/specimen.go
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvados
+
+import "time"
+
+type Specimen struct {
+	UUID       string                 `json:"uuid"`
+	OwnerUUID  string                 `json:"owner_uuid"`
+	CreatedAt  time.Time              `json:"created_at"`
+	ModifiedAt time.Time              `json:"modified_at"`
+	UpdatedAt  time.Time              `json:"updated_at"`
+	Properties map[string]interface{} `json:"properties"`
+}
+
+type SpecimenList struct {
+	Items          []Specimen `json:"items"`
+	ItemsAvailable int        `json:"items_available"`
+	Offset         int        `json:"offset"`
+	Limit          int        `json:"limit"`
+}
diff --git a/sdk/go/auth/auth.go b/sdk/go/auth/auth.go
index 3c266e0d3..de3b1e952 100644
--- a/sdk/go/auth/auth.go
+++ b/sdk/go/auth/auth.go
@@ -20,7 +20,7 @@ func NewCredentials() *Credentials {
 }
 
 func CredentialsFromRequest(r *http.Request) *Credentials {
-	if c, ok := r.Context().Value(contextKeyCredentials).(*Credentials); ok {
+	if c, ok := r.Context().Value(ContextKeyCredentials).(*Credentials); ok {
 		// preloaded by middleware
 		return c
 	}
diff --git a/sdk/go/auth/handlers.go b/sdk/go/auth/handlers.go
index ad1fa5141..9fa501ab7 100644
--- a/sdk/go/auth/handlers.go
+++ b/sdk/go/auth/handlers.go
@@ -11,15 +11,15 @@ import (
 
 type contextKey string
 
-var contextKeyCredentials contextKey = "credentials"
+var ContextKeyCredentials contextKey = "credentials"
 
 // LoadToken wraps the next handler, adding credentials to the request
 // context so subsequent handlers can access them efficiently via
 // CredentialsFromRequest.
 func LoadToken(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if _, ok := r.Context().Value(contextKeyCredentials).(*Credentials); !ok {
-			r = r.WithContext(context.WithValue(r.Context(), contextKeyCredentials, CredentialsFromRequest(r)))
+		if _, ok := r.Context().Value(ContextKeyCredentials).(*Credentials); !ok {
+			r = r.WithContext(context.WithValue(r.Context(), ContextKeyCredentials, CredentialsFromRequest(r)))
 		}
 		next.ServeHTTP(w, r)
 	})
diff --git a/sdk/go/httpserver/error.go b/sdk/go/httpserver/error.go
index 1ccf8c047..b222e18ea 100644
--- a/sdk/go/httpserver/error.go
+++ b/sdk/go/httpserver/error.go
@@ -14,10 +14,7 @@ type ErrorResponse struct {
 }
 
 func Error(w http.ResponseWriter, error string, code int) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("X-Content-Type-Options", "nosniff")
-	w.WriteHeader(code)
-	json.NewEncoder(w).Encode(ErrorResponse{Errors: []string{error}})
+	Errors(w, []string{error}, code)
 }
 
 func Errors(w http.ResponseWriter, errors []string, code int) {
diff --git a/sdk/go/keepclient/keepclient.go b/sdk/go/keepclient/keepclient.go
index ab610d65e..c8dd09de8 100644
--- a/sdk/go/keepclient/keepclient.go
+++ b/sdk/go/keepclient/keepclient.go
@@ -551,7 +551,7 @@ func (kc *KeepClient) httpClient() HTTPClient {
 		// It's not safe to copy *http.DefaultTransport
 		// because it has a mutex (which might be locked)
 		// protecting a private map (which might not be nil).
-		// So we build our own, using the Go 1.10 default
+		// So we build our own, using the Go 1.12 default
 		// values, ignoring any changes the application has
 		// made to http.DefaultTransport.
 		Transport: &http.Transport{
@@ -563,7 +563,7 @@ func (kc *KeepClient) httpClient() HTTPClient {
 			MaxIdleConns:          100,
 			IdleConnTimeout:       90 * time.Second,
 			TLSHandshakeTimeout:   tlsTimeout,
-			ExpectContinueTimeout: time.Second,
+			ExpectContinueTimeout: 1 * time.Second,
 			TLSClientConfig:       arvadosclient.MakeTLSConfig(kc.Arvados.ApiInsecure),
 		},
 	}
diff --git a/services/crunch-run/crunchrun.go b/services/crunch-run/crunchrun.go
index 84b578a3e..3261291b5 100644
--- a/services/crunch-run/crunchrun.go
+++ b/services/crunch-run/crunchrun.go
@@ -987,7 +987,7 @@ func (runner *ContainerRunner) AttachStreams() (err error) {
 		go func() {
 			_, err := io.Copy(response.Conn, stdinRdr)
 			if err != nil {
-				runner.CrunchLog.Printf("While writing stdin collection to docker container %q", err)
+				runner.CrunchLog.Printf("While writing stdin collection to docker container: %v", err)
 				runner.stop(nil)
 			}
 			stdinRdr.Close()
@@ -997,7 +997,7 @@ func (runner *ContainerRunner) AttachStreams() (err error) {
 		go func() {
 			_, err := io.Copy(response.Conn, bytes.NewReader(stdinJson))
 			if err != nil {
-				runner.CrunchLog.Printf("While writing stdin json to docker container %q", err)
+				runner.CrunchLog.Printf("While writing stdin json to docker container: %v", err)
 				runner.stop(nil)
 			}
 			response.CloseWrite()

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list