Repository: smithy-lang/smithy
Pull request #1995 (2023-09-28 22:32:29)

Add option to deconflict errors with same status code for OpenAPI
This PR closes #1649. See also: #1265.

It re-introduces a subset of the changes that were added previously in #1304, but later reverted in #1334. The docs changes from #1304 were not entirely reverted, so those are modified here to reflect the new changes, which include only allowing for the `oneOf` option to deconflict errors. Contrary to the docs for the previous PR, `oneOf` *is* supported by API Gateway when the `oneOf` is included within a component schema and referenced via a `$ref`. Using `oneOf` within the path content directly is not allowed.

Deconflicting errors is opt-in when converting to OpenAPI by setting the following in smithy-build.json:
```
{
    "plugins": {
        "openapi": {
            "service": "smithy.example#Weather",
            "onErrorStatusConflict": "oneOf"
        }
    }
}
```

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.



Patch:
diff --git a/docs/source-2.0/guides/converting-to-openapi.rst b/docs/source-2.0/guides/converting-to-openapi.rst
index 5b79ed0df..06f2569d6 100644
--- a/docs/source-2.0/guides/converting-to-openapi.rst
+++ b/docs/source-2.0/guides/converting-to-openapi.rst
@@ -598,37 +598,22 @@ disableIntegerFormat (``boolean``)
 .. _generate-openapi-setting-onErrorStatusConflict:
 
 onErrorStatusConflict (``String``)
-    Specifies how to resolve multiple error responses that share a same HTTP status code.
-    This behavior can be customized using the following values for the ``onErrorStatusConflict`` setting:
+    Specifies how to resolve multiple error responses that share the same HTTP
+    status code. This behavior can be enabled using the following values for
+    the ``onErrorStatusConflict`` setting:
 
     ``oneOf``
-        Use OpenAPI's ``oneOf`` keyword to combine error responses with same HTTP status code. The ``oneOf`` option
-        wraps schemas for contents of conflicting errors responses schemas into a synthetic union schema using
-        OpenAPI's ``oneOf`` keyword.
-    ``properties``
-        Use ``properties`` field of OpenAPI schema object to combine error responses with same HTTP status code.
-        The ``properties`` option combines the conflicting error structure shapes into one union error shape that
-        contains all members from each and every conflicting error.
-
-    .. note::
-            ``oneOf`` keyword is not supported by Amazon API Gateway.
-
-    Both options generate a single combined response object called "UnionError XXX Response" in the
-    OpenAPI model output, where "XXX" is the status code shared by multiple errors. Both options drop
-    the ``@required`` trait from all members of conflicting error structures, making them optional.
-
-    .. warning::
-        When using ``properties`` option, make sure that conflicting error structure shapes do not have member(s)
-        that have same name while having different target shapes. If member shapes with same name
-        (in conflicting error structures) target
-        different shapes, error shapes will not be able to be merged into one union error shape, and
-        an exception will be thrown.
-
-    .. warning::
-        Regardless of the setting, an exception will be thrown if any one of conflicting error structure shape
-        has a member shape with ``@httpPayload`` trait.
-
-    By default, this setting is set to ``oneOf``.
+        Use OpenAPI's ``oneOf`` keyword to combine error responses with same
+        HTTP status code. The ``oneOf`` option wraps schemas for contents of
+        conflicting errors responses schemas into a synthetic union schema
+        using OpenAPI's ``oneOf`` keyword.
+
+    By default, this setting is disabled. When enabled, a single combined
+    response object will be included in the OpenAPI model output. Any member of
+    the conflicting errors bound to a HTTP header will be added to the
+    top-level response. If any headers conflict, an error will be thrown.
+    Remaining members will be left in place on the conflicting errors. The
+    modified conflicting errors are then added to the combined response object.
 
     .. code-block:: json
         :caption: smithy-build.json
@@ -638,7 +623,7 @@ onErrorStatusConflict (``String``)
             "plugins": {
                 "openapi": {
                     "service": "smithy.example#Weather",
-                    "onErrorStatusConflict": "properties"
+                    "onErrorStatusConflict": "oneOf"
                 }
             }
         }
diff --git a/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/clashing-lockfile/smithy-build.json b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/clashing-lockfile/smithy-build.json
new file mode 100644
index 000000000..b70b9f2ae
--- /dev/null
+++ b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/clashing-lockfile/smithy-build.json
@@ -0,0 +1,15 @@
+{
+    "version": "1.0",
+    "maven": {
+        "repositories": [
+            {
+                // Use HTTP instead of HTTPS because we're running a mock server during tests
+                "id": "Artifactory 1",
+                "url": "http://localhost:5678/artifactory-1"
+            }
+        ],
+        "dependencies": [
+            "com.example:artifact:[1.0,)"
+        ]
+    }
+}
diff --git a/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/clashing-lockfile/smithy-lock.json b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/clashing-lockfile/smithy-lock.json
new file mode 100644
index 000000000..4a84728eb
--- /dev/null
+++ b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/clashing-lockfile/smithy-lock.json
@@ -0,0 +1,1 @@
+{"AUTOGENERATED_DO_NOT_MODIFY":"","version":"1.0","configHash":1317746633,"repositories":["http://localhost:5678/artifactory-1","http://localhost:5678/artifactory-2"],"artifacts":{"com.example:artifact:1.1.0":{"sha1":"23d5b855da1e9fb13e41db21296815d38cb31c08"}}}
diff --git a/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/lockfile-used/smithy-build.json b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/lockfile-used/smithy-build.json
new file mode 100644
index 000000000..7d6e52f1a
--- /dev/null
+++ b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/lockfile-used/smithy-build.json
@@ -0,0 +1,20 @@
+{
+    "version": "1.0",
+    "maven": {
+        "repositories": [
+            {
+                // Use HTTP instead of HTTPS because we're running a mock server during tests
+                "id": "Artifactory 1",
+                "url": "http://localhost:5678/artifactory-1"
+            },
+            {
+                // Use HTTP instead of HTTPS because we're running a mock server during tests
+                "id": "Artifactory 2",
+                "url": "http://localhost:5678/artifactory-2"
+            }
+        ],
+        "dependencies": [
+            "com.example:artifact:[1.0,)"
+        ]
+    }
+}
diff --git a/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/lockfile-used/smithy-lock.json b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/lockfile-used/smithy-lock.json
new file mode 100644
index 000000000..7401f7398
--- /dev/null
+++ b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/lockfile-used/smithy-lock.json
@@ -0,0 +1,1 @@
+{"AUTOGENERATED_DO_NOT_MODIFY":"","version":"1.0","configHash":-1211145608,"repositories":["http://localhost:5678/artifactory-1","http://localhost:5678/artifactory-2"],"artifacts":{"com.example:artifact:1.1.0":{"sha1":"23d5b855da1e9fb13e41db21296815d38cb31c08"}}}
diff --git a/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/lockfile-written/smithy-build.json b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/lockfile-written/smithy-build.json
new file mode 100644
index 000000000..e54f61666
--- /dev/null
+++ b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/lockfile-written/smithy-build.json
@@ -0,0 +1,20 @@
+{
+    "version": "1.0",
+    "maven": {
+        "repositories": [
+            {
+                // Use HTTP instead of HTTPS because we're running a mock server during tests
+                "id": "Artifactory 1",
+                "url": "http://localhost:6789/artifactory-1"
+            },
+            {
+                // Use HTTP instead of HTTPS because we're running a mock server during tests
+                "id": "Artifactory 2",
+                "url": "http://localhost:6789/artifactory-2"
+            }
+        ],
+        "dependencies": [
+            "com.example:artifact:[1.0,)"
+        ]
+    }
+}
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/BuildCommand.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/BuildCommand.java
index dc7ed6d3f..25508cdfd 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/BuildCommand.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/BuildCommand.java
@@ -86,11 +86,6 @@ final class BuildCommand implements Command {
         private String projection;
         private String plugin;
 
-        @Override
-        public boolean testOption(String name) {
-            return false;
-        }
-
         @Override
         public Consumer<String> testParameter(String name) {
             switch (name) {
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ClasspathAction.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ClasspathAction.java
index 75bf97a0f..9fd274c83 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ClasspathAction.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ClasspathAction.java
@@ -20,6 +20,7 @@ import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.Consumer;
 import java.util.logging.Logger;
@@ -41,11 +42,7 @@ import software.amazon.smithy.cli.dependencies.ResolvedArtifact;
  * A {@code CommandAction} that runs a wrapped action within a custom classpath.
  */
 class ClasspathAction implements CommandAction {
-
     private static final Logger LOGGER = Logger.getLogger(ClasspathAction.class.getName());
-    private static final MavenRepository CENTRAL = MavenRepository.builder()
-            .url("https://repo.maven.apache.org/maven2")
-            .build();
     private final DependencyResolver.Factory dependencyResolverFactory;
     private final CommandActionWithConfig action;
 
@@ -133,13 +130,33 @@ class ClasspathAction implements CommandAction {
         long lastModified = smithyBuildConfig.getLastModifiedInMillis();
         DependencyResolver delegate = new FilterCliVersionResolver(SmithyCli.getVersion(), baseResolver);
         DependencyResolver resolver = new FileCacheResolver(getCacheFile(buildOptions, smithyBuildConfig),
-                                                            lastModified,
-                                                            delegate);
-        addConfiguredMavenRepos(smithyBuildConfig, resolver);
-        maven.getDependencies().forEach(resolver::addDependency);
+                lastModified,
+                delegate);
+
+        Set<MavenRepository> repositories = ConfigurationUtils.getConfiguredMavenRepos(smithyBuildConfig);
+        repositories.forEach(resolver::addRepository);
+
+        // Use the pinned lockfile dependencies if a lockfile exists otherwise use the configured dependencies
+        Optional<LockFile> lockFileOptional = LockFile.load();
+        if (lockFileOptional.isPresent()) {
+            LockFile lockFile = lockFileOptional.get();
+            if (lockFile.getConfigHash() != ConfigurationUtils.configHash(maven.getDependencies(), repositories)) {
+                throw new CliError(
+                        "`smithy-lock.json` does not match configured dependencies. "
+                                + "Re-lock dependencies using the `lock` command or revert changes.");
+            }
+            LOGGER.fine(() -> "`smithy-lock.json` found. Using locked dependencies: "
+                    + lockFile.getDependencyCoordinateSet());
+            lockFile.getDependencyCoordinateSet().forEach(resolver::addDependency);
+        } else {
+            maven.getDependencies().forEach(resolver::addDependency);
+        }
+
         List<ResolvedArtifact> artifacts = resolver.resolve();
         LOGGER.fine(() -> "Classpath resolved with Maven: " + artifacts);
 
+        // Ensure resolved artifacts match pinned artifacts
+        lockFileOptional.ifPresent(lockFile -> lockFile.validateArtifacts(artifacts));
         List<Path> result = new ArrayList<>(artifacts.size());
         for (ResolvedArtifact artifact : artifacts) {
             result.add(artifact.getPath());
@@ -148,29 +165,6 @@ class ClasspathAction implements CommandAction {
         return result;
     }
 
-    private static void addConfiguredMavenRepos(SmithyBuildConfig config, DependencyResolver resolver) {
-        // Environment variables take precedence over config files.
-        String envRepos = EnvironmentVariable.SMITHY_MAVEN_REPOS.get();
-        if (envRepos != null) {
-            for (String repo : envRepos.split("\\|")) {
-                resolver.addRepository(MavenRepository.builder().url(repo.trim()).build());
-            }
-        }
-
-        Set<MavenRepository> configuredRepos = config.getMaven()
-                .map(MavenConfig::getRepositories)
-                .orElse(Collections.emptySet());
-
-        if (!configuredRepos.isEmpty()) {
-            configuredRepos.forEach(resolver::addRepository);
-        } else if (envRepos == null) {
-            LOGGER.finest(() -> String.format("maven.repositories is not defined in smithy-build.json and the %s "
-                                              + "environment variable is not set. Defaulting to Maven Central.",
-                                              EnvironmentVariable.SMITHY_MAVEN_REPOS));
-            resolver.addRepository(CENTRAL);
-        }
-    }
-
     private File getCacheFile(BuildOptions buildOptions, SmithyBuildConfig config) {
         return buildOptions.resolveOutput(config).resolve("classpath.json").toFile();
     }
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ConfigurationUtils.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ConfigurationUtils.java
new file mode 100644
index 000000000..9df1979ed
--- /dev/null
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ConfigurationUtils.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.cli.commands;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.logging.Logger;
+import software.amazon.smithy.build.model.MavenConfig;
+import software.amazon.smithy.build.model.MavenRepository;
+import software.amazon.smithy.build.model.SmithyBuildConfig;
+import software.amazon.smithy.cli.EnvironmentVariable;
+
+final class ConfigurationUtils {
+    private static final int FNV_OFFSET_BIAS = 0x811c9dc5;
+    private static final int FNV_PRIME = 0x1000193;
+    private static final Logger LOGGER = Logger.getLogger(ConfigurationUtils.class.getName());
+    private static final MavenRepository CENTRAL = MavenRepository.builder()
+            .url("https://repo.maven.apache.org/maven2")
+            .build();
+
+    private ConfigurationUtils() {
+        // Utility Class should not be instantiated
+    }
+
+    /**
+     * Gets the list of Maven Repos configured via smithy-build config and environment variables.
+     * <p>
+     * Environment variables take precedence over config file entries. If no repositories are
+     * defined in either environment variables or config file entries, then the returned set
+     * defaults to a singleton set of just the Maven Central repo.
+     *
+     * @param config Smithy build config to check for maven repository values.
+     */
+    public static Set<MavenRepository> getConfiguredMavenRepos(SmithyBuildConfig config) {
+        Set<MavenRepository> repositories = new LinkedHashSet<>();
+
+        String envRepos = EnvironmentVariable.SMITHY_MAVEN_REPOS.get();
+        if (envRepos != null) {
+            for (String repo : envRepos.split("\\|")) {
+                repositories.add(MavenRepository.builder().url(repo.trim()).build());
+            }
+        }
+
+        Set<MavenRepository> configuredRepos = config.getMaven()
+                .map(MavenConfig::getRepositories)
+                .orElse(Collections.emptySet());
+
+        if (!configuredRepos.isEmpty()) {
+            repositories.addAll(configuredRepos);
+        } else if (envRepos == null) {
+            LOGGER.finest(() -> String.format("maven.repositories is not defined in `smithy-build.json` and the %s "
+                            + "environment variable is not set. Defaulting to Maven Central.",
+                    EnvironmentVariable.SMITHY_MAVEN_REPOS));
+            repositories.add(CENTRAL);
+        }
+        return repositories;
+    }
+
+    /**
+     * Computes the combined hash of the artifact set and repositories set.
+     *
+     * @param artifacts set of requested artifact coordinates
+     * @param repositories set of repositories used for dependency resolution
+     * @return combined hash
+     */
+    public static int configHash(Set<String> artifacts, Set<MavenRepository> repositories) {
+        int result = 0;
+        for (String artifact : artifacts) {
+            result = 31 * result + getFnvHash(artifact);
+        }
+        for (MavenRepository repo : repositories) {
+            result = 31 * result + getFnvHash(repo.getUrl());
+        }
+        return result;
+    }
+
+    private static int getFnvHash(CharSequence text) {
+        int hashCode = FNV_OFFSET_BIAS;
+        for (int i = 0; i < text.length(); i++) {
+            hashCode = (hashCode ^ text.charAt(i)) * FNV_PRIME;
+        }
+        return hashCode;
+    }
+}
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/LockCommand.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/LockCommand.java
new file mode 100644
index 000000000..4fd9299ae
--- /dev/null
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/LockCommand.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.cli.commands;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.logging.Logger;
+import software.amazon.smithy.build.model.MavenConfig;
+import software.amazon.smithy.build.model.MavenRepository;
+import software.amazon.smithy.build.model.SmithyBuildConfig;
+import software.amazon.smithy.cli.Arguments;
+import software.amazon.smithy.cli.Command;
+import software.amazon.smithy.cli.SmithyCli;
+import software.amazon.smithy.cli.dependencies.DependencyResolver;
+import software.amazon.smithy.cli.dependencies.FilterCliVersionResolver;
+import software.amazon.smithy.cli.dependencies.ResolvedArtifact;
+
+
+final class LockCommand implements Command {
+    private static final Logger LOGGER = Logger.getLogger(LockCommand.class.getName());
+
+    private final String parentCommandName;
+    private final DependencyResolver.Factory dependencyResolverFactory;
+
+    LockCommand(String parentCommandName, DependencyResolver.Factory dependencyResolverFactory) {
+        this.parentCommandName = parentCommandName;
+        this.dependencyResolverFactory = dependencyResolverFactory;
+    }
+
+    @Override
+    public String getName() {
+        return "lock";
+    }
+
+    @Override
+    public String getSummary() {
+        return "Resolves dependencies and generates a lockfile to pin to the resolved dependency versions.";
+    }
+
+    @Override
+    public int execute(Arguments arguments, Env env) {
+        arguments.addReceiver(new ConfigOptions());
+
+        CommandAction action = HelpActionWrapper.fromCommand(this, parentCommandName, this::run);
+
+        return action.apply(arguments, env);
+    }
+
+    private int run(Arguments arguments, Env env) {
+        ConfigOptions configOptions = arguments.getReceiver(ConfigOptions.class);
+        SmithyBuildConfig smithyBuildConfig = configOptions.createSmithyBuildConfig();
+
+        DependencyResolver baseResolver = dependencyResolverFactory.create(smithyBuildConfig, env);
+        DependencyResolver resolver = new FilterCliVersionResolver(SmithyCli.getVersion(), baseResolver);
+
+        Set<MavenRepository> repositories = ConfigurationUtils.getConfiguredMavenRepos(smithyBuildConfig);
+        Set<String> dependencies = smithyBuildConfig.getMaven()
+                .map(MavenConfig::getDependencies)
+                .orElse(Collections.emptySet());
+        dependencies.forEach(resolver::addDependency);
+        repositories.forEach(resolver::addRepository);
+
+        List<ResolvedArtifact> resolvedArtifacts = resolver.resolve();
+        LOGGER.fine(() -> "Resolved artifacts with Maven: " + resolvedArtifacts);
+        LockFile lock = LockFile.builder()
+                .configHash(ConfigurationUtils.configHash(dependencies, repositories))
+                .artifacts(resolvedArtifacts)
+                .repositories(repositories)
+                .build();
+        LOGGER.fine(() -> "Saving resolved artifacts to lockfile.");
+        lock.save();
+
+        return 0;
+    }
+}
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/LockFile.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/LockFile.java
new file mode 100644
index 000000000..32f0424e4
--- /dev/null
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/LockFile.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.cli.commands;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import software.amazon.smithy.build.model.MavenRepository;
+import software.amazon.smithy.cli.CliError;
+import software.amazon.smithy.cli.dependencies.DependencyResolverException;
+import software.amazon.smithy.cli.dependencies.ResolvedArtifact;
+import software.amazon.smithy.model.node.ArrayNode;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.node.ObjectNode;
+import software.amazon.smithy.model.node.StringNode;
+import software.amazon.smithy.model.node.ToNode;
+import software.amazon.smithy.utils.BuilderRef;
+import software.amazon.smithy.utils.IoUtils;
+import software.amazon.smithy.utils.ListUtils;
+import software.amazon.smithy.utils.SmithyBuilder;
+import software.amazon.smithy.utils.ToSmithyBuilder;
+
+final class LockFile implements ToSmithyBuilder<LockFile>, ToNode {
+    private static final Path LOCK_FILE = Paths.get("smithy-lock.json");
+    private static final String DEFAULT_VERSION = "1.0";
+    private static final String WARNING_STR = "AUTOGENERATED_DO_NOT_MODIFY";
+    private static final String VERSION_NAME = "version";
+    private static final String CONFIG_HASH_NAME = "configHash";
+    private static final String ARTIFACT_LIST_NAME = "artifacts";
+    private static final String REPOSITORIES_LIST_NAME = "repositories";
+    private static final List<String> EXPECTED_PROPERTIES = ListUtils.of(
+            WARNING_STR,
+            VERSION_NAME,
+            CONFIG_HASH_NAME,
+            ARTIFACT_LIST_NAME,
+            REPOSITORIES_LIST_NAME
+    );
+
+    private final Map<String, LockedArtifact> artifacts;
+    private final Set<String> repositories;
+    private final int configHash;
+    private final String version;
+
+    private LockFile(Builder builder) {
+        this.artifacts = builder.artifacts.copy();
+        this.repositories =  builder.repositories.copy();
+        this.version = builder.version;
+        this.configHash = builder.configHash;
+    }
+
+    public static LockFile fromNode(Node node) {
+        LockFile.Builder builder = builder();
+        node.expectObjectNode()
+                .warnIfAdditionalProperties(EXPECTED_PROPERTIES)
+                .getStringMember(VERSION_NAME, builder::version)
+                .getNumberMember(CONFIG_HASH_NAME, n -> builder.configHash(n.intValue()))
+                .getObjectMember(ARTIFACT_LIST_NAME, artifactList -> {
+                    for (Map.Entry<String, Node> entry : artifactList.getStringMap().entrySet()) {
+                        builder.artifact(LockedArtifact.fromCoordinateNode(entry.getKey(), entry.getValue()));
+                    }
+                })
+                .getArrayMember(REPOSITORIES_LIST_NAME, StringNode::getValue, builder::repositories);
+        return builder.build();
+    }
+
+    public static Optional<LockFile> load() {
+        return load(LOCK_FILE);
+    }
+
+    /**
+     * Loads lockfile from path if it exists.
+     *
+     * @param path path of file to load as a lockfile
+     * @return Lockfile if it exists otherwise Optional.empty().
+     */
+    public static Optional<LockFile> load(Path path) {
+        if (Files.exists(path) && Files.isRegularFile(path)) {
+            String contents = IoUtils.readUtf8File(path);
+            Node result = Node.parseJsonWithComments(contents);
+            return Optional.of(LockFile.fromNode(result));
+        }
+        return Optional.empty();
+    }
+
+
+    @Override
+    public Node toNode() {
+        ObjectNode.Builder builder = Node.objectNodeBuilder()
+                .withMember(WARNING_STR, "")
+                .withMember(VERSION_NAME, version)
+                .withMember(CONFIG_HASH_NAME, configHash)
+                .withMember(REPOSITORIES_LIST_NAME, ArrayNode.fromStrings(repositories));
+
+        ObjectNode.Builder artifactNodeBuilder = Node.objectNodeBuilder();
+        for (LockedArtifact artifact : artifacts.values()) {
+            artifactNodeBuilder.withMember(artifact.coordinates, artifact.toNode());
+        }
+        builder.withMember(ARTIFACT_LIST_NAME, artifactNodeBuilder.build());
+
+        return builder.build();
+    }
+
+    public void validateArtifacts(List<ResolvedArtifact> resolvedArtifacts) {
+        for (ResolvedArtifact artifact : resolvedArtifacts) {
+            LockedArtifact pin = artifacts.get(artifact.getCoordinates());
+            if (pin == null) {
+                throw new CliError("Resolved artifact `" + artifact + "` not found in locked artifacts.");
+            }
+            if (!pin.matchesResolved(artifact)) {
+                throw new CliError("Resolved artifact `" + artifact + "` does not match locked artifact `" + pin + "`");
+            }
+        }
+    }
+
+    public static LockFile.Builder builder() {
+        return new LockFile.Builder();
+    }
+
+    /**
+     * @return Gets the configuration hash for the lockfile
+     */
+    public int getConfigHash() {
+        return configHash;
+    }
+
+    /**
+     * @return Gets the list of pinned artifacts.
+     */
+    public Set<String> getDependencyCoordinateSet() {
+        return artifacts.keySet();
+    }
+
+    /**
+     * @return Gets the version of lockfile.
+     */
+    public String getVersion() {
+        return version;
+    }
+
+    /**
+     * @return Gets the pinned repositories in the lockfile.
+     */
+    public Set<String> getRepositories() {
+        return repositories;
+    }
+
+    /**
+     * Saves lockfile to current working directory.
+     */
+    public void save() {
+        try {
+            Files.write(LOCK_FILE, Node.printJson(this.toNode()).getBytes(StandardCharsets.UTF_8));
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public LockFile.Builder toBuilder() {
+        return builder().repositories(repositories)
+                .artifacts(artifacts)
+                .version(version)
+                .configHash(configHash);
+    }
+
+    public static final class Builder implements SmithyBuilder<LockFile> {
+        private final BuilderRef<Map<String, LockedArtifact>> artifacts = BuilderRef.forOrderedMap();
+        private final BuilderRef<Set<String>> repositories = BuilderRef.forOrderedSet();
+        private String version = DEFAULT_VERSION;
+        private int configHash;
+
+        public LockFile.Builder artifacts(Map<String, LockedArtifact> artifacts) {
+            this.artifacts.clear();
+            this.artifacts.get().putAll(artifacts);
+            return this;
+        }
+
+        public LockFile.Builder artifacts(Collection<ResolvedArtifact> artifacts) {
+            this.artifacts.clear();
+            for (ResolvedArtifact artifact : artifacts) {
+                this.artifacts.get().put(artifact.getCoordinates(), LockedArtifact.from(artifact));
+            }
+            return this;
+        }
+
+        public LockFile.Builder artifact(ResolvedArtifact artifact) {
+            this.artifacts.get().put(artifact.getCoordinates(), LockedArtifact.from(artifact));
+            return this;
+        }
+
+        public LockFile.Builder artifact(LockedArtifact artifact) {
+            this.artifacts.get().put(artifact.coordinates, artifact);
+            return this;
+        }
+
+        public LockFile.Builder repositories(Set<MavenRepository> repositories) {
+            this.repositories.clear();
+            for (MavenRepository repository : repositories) {
+                this.repositories.get().add(repository.getUrl());
+            }
+            return this;
+        }
+
+        public LockFile.Builder repositories(Collection<String> repositories) {
+            this.repositories.clear();
+            this.repositories.get().addAll(repositories);
+            return this;
+        }
+
+        public LockFile.Builder version(String version) {
+            this.version = version;
+            return this;
+        }
+
+        public LockFile.Builder configHash(int configHash) {
+            this.configHash = configHash;
+            return this;
+        }
+
+        @Override
+        public LockFile build() {
+            return new LockFile(this);
+        }
+    }
+
+    private static final class LockedArtifact implements ToNode {
+        private static final String SHA_SUM_MEMBER_NAME = "sha1";
+        private final String coordinates;
+        private final String shaSum;
+
+        private LockedArtifact(String coordinates, String shaSum) {
+            this.coordinates = coordinates;
+            this.shaSum = shaSum;
+        }
+
+        private static LockedArtifact from(ResolvedArtifact resolvedArtifact) {
+            return new LockedArtifact(resolvedArtifact.getCoordinates(), resolvedArtifact.getShaSum());
+        }
+
+        private static LockedArtifact fromCoordinateNode(String coordinates, Node node) {
+            String[] parts = coordinates.split(":");
+            if (parts.length != 3) {
+                throw new DependencyResolverException("Invalid Locked coordinates: " + coordinates);
+            }
+            ObjectNode objectNode = node.expectObjectNode();
+            String shaSum = objectNode.expectMember(SHA_SUM_MEMBER_NAME).expectStringNode().getValue();
+
+            return new LockedArtifact(coordinates, shaSum);
+        }
+
+        @Override
+        public Node toNode() {
+            return Node.objectNodeBuilder()
+                    .withMember(SHA_SUM_MEMBER_NAME, shaSum)
+                    .build();
+        }
+
+        boolean matchesResolved(ResolvedArtifact resolved) {
+           return shaSum.equals(resolved.getShaSum());
+        }
+    }
+}
+
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/SmithyCommand.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/SmithyCommand.java
index 80bad5a3a..eb67a44ed 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/SmithyCommand.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/SmithyCommand.java
@@ -51,7 +51,8 @@ public final class SmithyCommand implements Command {
             migrateCommand,
             deprecated1To2Command,
             new WarmupCommand(getName()),
-            new InitCommand(getName())
+            new InitCommand(getName()),
+            new LockCommand(getName(), dependencyResolverFactory)
         );
     }
 
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/DependencyUtils.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/DependencyUtils.java
index d87ccad88..8ce4cdc9d 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/DependencyUtils.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/DependencyUtils.java
@@ -15,7 +15,9 @@ import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 
 final class DependencyUtils {
-    private DependencyUtils() {}
+    private DependencyUtils() {
+        // Utility Class should not be instantiated
+    }
 
     /**
      * Computes the sha1 digest of a file.
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/transform/DeconflictErrorsWithSharedStatusCode.java b/smithy-model/src/main/java/software/amazon/smithy/model/transform/DeconflictErrorsWithSharedStatusCode.java
new file mode 100644
index 000000000..c9b88355a
--- /dev/null
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/transform/DeconflictErrorsWithSharedStatusCode.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.model.transform;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.knowledge.TopDownIndex;
+import software.amazon.smithy.model.shapes.MemberShape;
+import software.amazon.smithy.model.shapes.OperationShape;
+import software.amazon.smithy.model.shapes.ServiceShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.shapes.StructureShape;
+import software.amazon.smithy.model.shapes.UnionShape;
+import software.amazon.smithy.model.traits.ErrorTrait;
+import software.amazon.smithy.model.traits.HttpErrorTrait;
+import software.amazon.smithy.model.traits.HttpHeaderTrait;
+import software.amazon.smithy.model.traits.Trait;
+import software.amazon.smithy.utils.Pair;
+
+/**
+ * Deconflicts errors on operations that share the same status code by replacing
+ * the conflicting errors with a synthetic error structure that contains hoisted
+ * members that were bound to HTTP headers. The conflicting errors are added to
+ * a union on the synthetic error.
+ */
+final class DeconflictErrorsWithSharedStatusCode {
+
+    private final ServiceShape forService;
+
+    DeconflictErrorsWithSharedStatusCode(ServiceShape forService) {
+        this.forService = forService;
+    }
+
+    Model transform(ModelTransformer transformer, Model model) {
+        // Copy any service errors to the operations to find all potential conflicts.
+        model = transformer.copyServiceErrorsToOperations(model, forService);
+        TopDownIndex topDownIndex = TopDownIndex.of(model);
+        List<Shape> shapesToReplace = new ArrayList<>();
+
+        for (OperationShape operation : topDownIndex.getContainedOperations(forService)) {
+            OperationShape.Builder replacementOperation = operation.toBuilder();
+            boolean replaceOperation = false;
+
+            // Collect errors that share the same status code.
+            Map<Integer, List<StructureShape>> statusCodesToErrors = new HashMap<>();
+            for (ShapeId errorId : operation.getErrors()) {
+                StructureShape error = model.expectShape(errorId, StructureShape.class);
+                Integer statusCode = error.hasTrait(HttpErrorTrait.ID)
+                        ? error.getTrait(HttpErrorTrait.class).get().getCode()
+                        : error.getTrait(ErrorTrait.class).get().getDefaultHttpStatusCode();
+                statusCodesToErrors.computeIfAbsent(statusCode, k -> new ArrayList<>()).add(error);
+            }
+
+            // Create union error for errors with same status code.
+            for (Map.Entry<Integer, List<StructureShape>> statusCodeToErrors : statusCodesToErrors.entrySet()) {
+                if (statusCodeToErrors.getValue().size() > 1) {
+                    replaceOperation = true;
+                    List<StructureShape> errors = statusCodeToErrors.getValue();
+                    // Create a new top-level synthetic error and all the shapes that need replaced for it.
+                    Pair<Shape, List<Shape>> syntheticErrorPair = synthesizeErrorUnion(operation.getId().getName(),
+                            statusCodeToErrors.getKey(), errors);
+                    for (StructureShape error : errors) {
+                        replacementOperation.removeError(error.getId());
+                    }
+                    replacementOperation.addError(syntheticErrorPair.getLeft());
+                    shapesToReplace.add(syntheticErrorPair.getLeft());
+                    shapesToReplace.addAll(syntheticErrorPair.getRight());
+                }
+            }
+            // Replace the operation if it has been updated with a synthetic error.
+            if (replaceOperation) {
+                replacementOperation.build();
+                shapesToReplace.add(replacementOperation.build());
+            }
+        }
+
+        return transformer.replaceShapes(model, shapesToReplace);
+    }
+
+    // Return synthetic error, along with any updated shapes.
+    private Pair<Shape, List<Shape>> synthesizeErrorUnion(String operationName, Integer statusCode,
+                                                          List<StructureShape> errors) {
+        List<Shape> replacementShapes = new ArrayList<>();
+        StructureShape.Builder errorResponse = StructureShape.builder();
+        ShapeId errorResponseId = ShapeId.fromParts(forService.getId().getNamespace(),
+                operationName + statusCode + "Error");
+        errorResponse.id(errorResponseId);
+        errorResponse.addTraits(getErrorTraitsFromStatusCode(statusCode));
+        Map<String, HttpHeaderTrait> headerTraitMap = new HashMap<>();
+        UnionShape.Builder errorUnion = UnionShape.builder().id(
+                ShapeId.fromParts(errorResponseId.getNamespace(), errorResponseId.getName() + "Content"));
+        for (StructureShape error : errors) {
+            StructureShape newError = createNewError(error, headerTraitMap);
+            replacementShapes.add(newError);
+            MemberShape newErrorMember = MemberShape.builder()
+                    .id(errorUnion.getId().withMember(newError.getId().getName()))
+                    .target(newError.getId())
+                    .build();
+            replacementShapes.add(newErrorMember);
+            errorUnion.addMember(newErrorMember);
+        }
+        UnionShape union = errorUnion.build();
+        replacementShapes.add(union);
+        errorResponse.addMember(MemberShape.builder()
+                .id(errorResponseId.withMember("errorUnion"))
+                .target(union.getId())
+                .build());
+        // Add members with hoisted HttpHeader traits.
+        for (Map.Entry<String, HttpHeaderTrait> entry : headerTraitMap.entrySet()) {
+            errorResponse.addMember(MemberShape.builder().id(errorResponseId.withMember(entry.getKey()))
+                    .addTrait(entry.getValue()).target("smithy.api#String").build());
+        }
+        StructureShape built = errorResponse.build();
+        return Pair.of(built, replacementShapes);
+    }
+
+    private StructureShape createNewError(StructureShape oldError, Map<String, HttpHeaderTrait> headerMap) {
+        StructureShape.Builder newErrorBuilder = oldError.toBuilder().clearMembers();
+        for (MemberShape member : oldError.getAllMembers().values()) {
+            String name = member.getMemberName();
+            // Collect HttpHeaderTraits to hoist.
+            if (member.hasTrait(HttpHeaderTrait.ID)) {
+                HttpHeaderTrait newTrait = member.expectTrait(HttpHeaderTrait.class);
+                HttpHeaderTrait previousTrait = headerMap.put(name, newTrait);
+                if (previousTrait != null && !previousTrait.equals(newTrait)) {
+                    throw new ModelTransformException("Conflicting header when de-conflicting");
+                }
+            } else {
+                newErrorBuilder.addMember(member.toBuilder().id(newErrorBuilder.getId().withMember(name)).build());
+            }
+        }
+        return newErrorBuilder.build();
+    }
+
+    private List<Trait> getErrorTraitsFromStatusCode(Integer statusCode) {
+        List<Trait> traits = new ArrayList<>();
+        if (statusCode >= 400 && statusCode < 500) {
+            traits.add(new ErrorTrait("client"));
+        } else {
+            traits.add(new ErrorTrait("server"));
+        }
+        traits.add(new HttpErrorTrait(statusCode));
+        return traits;
+    }
+}
+
+
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/transform/ModelTransformer.java b/smithy-model/src/main/java/software/amazon/smithy/model/transform/ModelTransformer.java
index e822cb38a..5d5ea4769 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/transform/ModelTransformer.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/transform/ModelTransformer.java
@@ -665,5 +665,16 @@ public final class ModelTransformer {
      */
     public Model removeInvalidDefaults(Model model) {
         return new RemoveInvalidDefaults().transform(this, model);
+
+    }
+
+    /**
+     * Deconflicts errors that share a status code.
+     *
+     * @param model Model to transform.
+     * @return Returns the transformed model.
+     */
+    public Model deconflictErrorsWithSharedStatusCode(Model model, ServiceShape forService) {
+        return new DeconflictErrorsWithSharedStatusCode(forService).transform(this, model);
     }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/RangeTraitPlugin.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/RangeTraitPlugin.java
index 5cf2e6386..058a82b84 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/RangeTraitPlugin.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/RangeTraitPlugin.java
@@ -30,6 +30,7 @@ import software.amazon.smithy.model.validation.Severity;
 class RangeTraitPlugin implements NodeValidatorPlugin {
     private static final String MEMBER = "Member";
     private static final String TARGET = "Target";
+    private static final String INVALID_RANGE = "InvalidRange";
 
     @Override
     public final void apply(Shape shape, Node value, Context context, Emitter emitter) {
@@ -56,14 +57,16 @@ class RangeTraitPlugin implements NodeValidatorPlugin {
                 emitter.accept(node, getSeverity(context), String.format(
                         "Value provided for `%s` must be greater than or equal to %s, but found \"%s\"",
                         shape.getId(), trait.getMin().get(), node.getValue()),
-                        shape.isMemberShape() ? MEMBER : TARGET);
+                        shape.isMemberShape() ? MEMBER : TARGET,
+                        INVALID_RANGE);
             }
 
             if (trait.getMax().isPresent() && value.equals(NonNumericFloat.POSITIVE_INFINITY)) {
                 emitter.accept(node, getSeverity(context), String.format(
                         "Value provided for `%s` must be less than or equal to %s, but found \"%s\"",
                         shape.getId(), trait.getMax().get(), node.getValue()),
-                        shape.isMemberShape() ? MEMBER : TARGET);
+                        shape.isMemberShape() ? MEMBER : TARGET,
+                        INVALID_RANGE);
             }
         });
     }
@@ -75,7 +78,8 @@ class RangeTraitPlugin implements NodeValidatorPlugin {
                     emitter.accept(node, getSeverity(node, context), String.format(
                                            "Value provided for `%s` must be greater than or equal to %s, but found %s",
                                            shape.getId(), min, decimal),
-                                   shape.isMemberShape() ? MEMBER : TARGET);
+                                   shape.isMemberShape() ? MEMBER : TARGET,
+                                   INVALID_RANGE);
                 }
             });
         });
@@ -86,7 +90,8 @@ class RangeTraitPlugin implements NodeValidatorPlugin {
                     emitter.accept(node, getSeverity(node, context), String.format(
                                            "Value provided for `%s` must be less than or equal to %s, but found %s",
                                            shape.getId(), max, decimal),
-                                   shape.isMemberShape() ? MEMBER : TARGET);
+                                   shape.isMemberShape() ? MEMBER : TARGET,
+                                   INVALID_RANGE);
                 }
             });
         });
diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/OpenApiConfig.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/OpenApiConfig.java
index 43f6ea6b4..cf0db1894 100644
--- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/OpenApiConfig.java
+++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/OpenApiConfig.java
@@ -46,6 +46,23 @@ public class OpenApiConfig extends JsonSchemaConfig {
         WARN
     }
 
+    /** Specifies how to resolve multiple error responses with same error codes. */
+    public enum ErrorStatusConflictHandlingStrategy {
+        /** The default setting that uses OpenAPI's oneOf keyword to combine multiple schemas for same media type. */
+        ONE_OF("oneOf");
+
+        private final String stringValue;
+
+        ErrorStatusConflictHandlingStrategy(String stringValue) {
+            this.stringValue = stringValue;
+        }
+
+        @Override
+        public String toString() {
+            return stringValue;
+        }
+    }
+
     /** The JSON pointer to where OpenAPI schema components should be written. */
     private static final String SCHEMA_COMPONENTS_POINTER = "#/components/schemas";
 
@@ -84,6 +101,7 @@ public class OpenApiConfig extends JsonSchemaConfig {
     private List<String> externalDocs = ListUtils.of(
             "Homepage", "API Reference", "User Guide", "Developer Guide", "Reference", "Guide");
     private boolean disableIntegerFormat = false;
+    private ErrorStatusConflictHandlingStrategy onErrorStatusConflict;
     private OpenApiVersion version = OpenApiVersion.VERSION_3_0_2;
 
     public OpenApiConfig() {
@@ -336,6 +354,20 @@ public class OpenApiConfig extends JsonSchemaConfig {
         this.disableIntegerFormat = disableIntegerFormat;
     }
 
+
+    public ErrorStatusConflictHandlingStrategy getOnErrorStatusConflict() {
+        return onErrorStatusConflict;
+    }
+
+    /**
+     * Specifies what to do when multiple error responses share the same HTTP status code.
+     *
+     * @param onErrorStatusConflict Strategy to use for multiple errors with same status code.
+     */
+    public void setOnErrorStatusConflict(ErrorStatusConflictHandlingStrategy onErrorStatusConflict) {
+        this.onErrorStatusConflict = Objects.requireNonNull(onErrorStatusConflict);
+    }
+
     /**
      * Creates an OpenApiConfig from a Node value.
      *
diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverter.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverter.java
index fa7855f68..94b19f12c 100644
--- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverter.java
+++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverter.java
@@ -189,6 +189,11 @@ public final class OpenApiConverter {
         // Remove mixins from the model.
         model = ModelTransformer.create().flattenAndRemoveMixins(model);
 
+        // Dejjjqconflict errors that share the same status code.
+        if (OpenApiConfig.ErrorStatusConflictHandlingStrategy.ONE_OF == config.getOnErrorStatusConflict()) {
+            model = ModelTransformer.create().deconflictErrorsWithSharedStatusCode(model, service);
+        }
+
         JsonSchemaConverter.Builder jsonSchemaConverterBuilder = JsonSchemaConverter.builder();
         jsonSchemaConverterBuilder.model(model);
 
diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1Protocol.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1Protocol.java
index 5b589a0e6..a80d37be2 100644
--- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1Protocol.java
+++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1Protocol.java
@@ -15,6 +15,8 @@
 
 package software.amazon.smithy.openapi.fromsmithy.protocols;
 
+import static software.amazon.smithy.openapi.OpenApiConfig.ErrorStatusConflictHandlingStrategy.ONE_OF;
+
 import java.util.List;
 import java.util.Set;
 import java.util.TreeSet;
@@ -25,10 +27,13 @@ import software.amazon.smithy.jsonschema.Schema;
 import software.amazon.smithy.model.Model;
 import software.amazon.smithy.model.knowledge.HttpBinding;
 import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.MemberShape;
 import software.amazon.smithy.model.shapes.OperationShape;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.shapes.ShapeId;
 import software.amazon.smithy.model.shapes.StructureShape;
+import software.amazon.smithy.model.shapes.UnionShape;
+import software.amazon.smithy.model.traits.HttpErrorTrait;
 import software.amazon.smithy.model.traits.TimestampFormatTrait;
 import software.amazon.smithy.openapi.OpenApiConfig;
 import software.amazon.smithy.openapi.fromsmithy.Context;
@@ -129,9 +134,45 @@ public final class AwsRestJson1Protocol extends AbstractRestProtocol<RestJson1Tr
         }
 
         StructureShape cleanedShape = containerShapeBuilder.build();
+
+        // If errors with the same status code have been deconflicted,
+        // hoist the members from the synthetic target union and convert
+        // the error structure to a union, so that a `oneOf` can be used
+        // in the output without added nesting.
+        if (context.getConfig().getOnErrorStatusConflict() != null
+                && context.getConfig().getOnErrorStatusConflict().equals(ONE_OF)
+                && targetsSyntheticError(cleanedShape, context)) {
+            UnionShape.Builder asUnion = UnionShape.builder().id(cleanedShape.getId());
+            UnionShape targetUnion = context.getModel().expectShape(
+                    cleanedShape.getAllMembers().values().stream().findFirst().get().getTarget(), UnionShape.class);
+            for (MemberShape member : targetUnion.getAllMembers().values()) {
+                String name = member.getMemberName();
+                asUnion.addMember(member.toBuilder().id(cleanedShape.getId().withMember(name)).build());
+            }
+            return context.getJsonSchemaConverter().convertShape(asUnion.build()).getRootSchema();
+        }
         return context.getJsonSchemaConverter().convertShape(cleanedShape).getRootSchema();
     }
 
+    private boolean targetsSyntheticError(StructureShape shape, Context context) {
+        if (shape.hasTrait(HttpErrorTrait.ID)) {
+            HttpErrorTrait trait = shape.expectTrait(HttpErrorTrait.class);
+            String suffix = trait.getCode() + "Error";
+            if (shape.getId().getName().endsWith(suffix)) {
+                return hasSingleUnionMember(shape, context.getModel());
+            }
+        }
+        return false;
+    }
+
+    private boolean hasSingleUnionMember(StructureShape shape, Model model) {
+        long unionCount = shape.getAllMembers().values().stream()
+                .map(member -> model.expectShape(member.getTarget()))
+                .filter(Shape::isUnionShape)
+                .count();
+        return unionCount == 1;
+    }
+
     @Override
     Node transformSmithyValueToProtocolValue(Node value) {
         return value;


Test patch:
diff --git a/smithy-cli/src/it/java/software/amazon/smithy/cli/LockCommandTest.java b/smithy-cli/src/it/java/software/amazon/smithy/cli/LockCommandTest.java
new file mode 100644
index 000000000..55d29f25a
--- /dev/null
+++ b/smithy-cli/src/it/java/software/amazon/smithy/cli/LockCommandTest.java
@@ -0,0 +1,176 @@
+package software.amazon.smithy.cli;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+import org.mockserver.integration.ClientAndServer;
+import software.amazon.smithy.utils.ListUtils;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockserver.integration.ClientAndServer.startClientAndServer;
+import static software.amazon.smithy.cli.MavenResolverMultipleReposTest.mockArtifactAndSha;
+
+@Isolated
+public class LockCommandTest {
+
+    @Test
+    public void writesLockfile() throws IOException {
+        ClientAndServer mockServer = null;
+        try {
+            mockServer = startClientAndServer(6789);
+
+            // artifact
+            mockArtifactAndSha(
+                    mockServer,
+                    "/artifactory-2/com/example/artifact/1.0.0/artifact-1.0.0.jar",
+                    "FAKE JAR CONTENT"
+            );
+
+            mockArtifactAndSha(
+                    mockServer,
+                    "/artifactory-2/com/example/artifact/maven-metadata.xml",
+                    getMetadataForVersions(ListUtils.of("1.0.0"))
+            );
+
+            mockArtifactAndSha(
+                    mockServer,
+                    "/artifactory-2/com/example/artifact/1.0.0/artifact-1.0.0.pom",
+                    pomFileContents()
+            );
+
+            // Dependency
+            mockArtifactAndSha(
+                    mockServer,
+                    "/artifactory-1/com/example/dependency/1.0.0/dependency-1.0.0.jar",
+                    "ANOTHER FAKE JAR"
+            );
+
+            // Use an empty cache
+            String cacheDir = Files.createTempDirectory("foo").toString();
+            Map<String, String> env = new HashMap<>();
+            env.put(EnvironmentVariable.SMITHY_MAVEN_CACHE.toString(), cacheDir);
+
+            IntegUtils.withProject("lockfile-written", root -> {
+                // Create initial lock file
+                RunResult lockResult = IntegUtils.run(root, ListUtils.of("lock", "--debug"), env);
+                assertThat(lockResult.getExitCode(), equalTo(0));
+                assertThat(lockResult.getOutput(), containsString("software.amazon.smithy.cli.dependencies.DependencyResolver - Resolved Maven dependencies: [com.example:artifact:jar:1.0.0"));
+                assertThat(lockResult.getOutput(), containsString("Saving resolved artifacts to lockfile."));
+
+                // Confirm lockfile detected in path and used
+                RunResult validateResult = IntegUtils.run(root, ListUtils.of("validate", "--debug"), env);
+                assertThat(validateResult.getExitCode(), equalTo(0));
+                assertThat(validateResult.getOutput(), containsString("`smithy-lock.json` found. Using locked dependencies: "));
+            });
+        } finally {
+            if (mockServer!=null) {
+                mockServer.stop();
+            }
+        }
+    }
+
+    @Test
+    public void lockedDependenciesUsed() {
+        ClientAndServer mockServer = null;
+        try {
+            mockServer = startClientAndServer(5678);
+
+            // artifacts
+            mockArtifactAndSha(
+                    mockServer,
+                    "/artifactory-2/com/example/artifact/1.0.0/artifact-1.0.0.jar",
+                    "FAKE JAR CONTENT"
+            );
+
+            mockArtifactAndSha(
+                    mockServer,
+                    "/artifactory-2/com/example/artifact/1.1.0/artifact-1.1.0.jar",
+                    "FAKE JAR CONTENT"
+            );
+
+            mockArtifactAndSha(
+                    mockServer,
+                    "/artifactory-2/com/example/artifact/1.2.0/artifact-1.2.0.jar",
+                    "FAKE JAR CONTENT"
+            );
+
+            mockArtifactAndSha(
+                    mockServer,
+                    "/artifactory-2/com/example/artifact/maven-metadata.xml",
+                    getMetadataForVersions(ListUtils.of("1.2.0", "1.1.0", "1.0.0"))
+            );
+
+            IntegUtils.runWithEmptyCache("lockfile-used", ListUtils.of("validate", "--debug"),
+                    Collections.emptyMap(), result -> {
+                        assertThat(result.getExitCode(), equalTo(0));
+                        assertThat(result.getOutput(), containsString("software.amazon.smithy.cli.dependencies.DependencyResolver - Resolved Maven dependencies: [com.example:artifact:jar:1.1.0"));
+                    });
+        } finally {
+            if (mockServer!=null) {
+                mockServer.stop();
+            }
+        }
+    }
+
+    @Test
+    public void clashingLockAndConfigThrowsException() {
+        IntegUtils.runWithEmptyCache("clashing-lockfile", ListUtils.of("validate", "--debug"),
+                Collections.emptyMap(), result -> {
+                    assertThat(result.getExitCode(), equalTo(1));
+                    assertThat(result.getOutput(), containsString("`smithy-lock.json` does not match configured dependencies."));
+                });
+    }
+
+    private String getMetadataForVersions(List<String> versions) {
+        String versionSection = versions.stream().map(v -> "<version>" + v + "</version>\n").collect(Collectors.joining());
+        return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
+                "<metadata modelVersion=\"1.1.0\">\n" +
+                "  <groupId>com.example</groupId>\n" +
+                "  <artifactId>artifact</artifactId>\n" +
+                "  <version>" + versions.get(0) + "</version>\n" +
+                "  <versioning>\n" +
+                "    <latest>" + versions.get(0) + "</latest>\n" +
+                "    <release>" + versions.get(0) + "</release>\n" +
+                "    <versions>\n" + versionSection +
+                "    </versions>\n" +
+                "    <lastUpdated>20230724184336</lastUpdated>\n" +
+                "  </versioning>\n" +
+                "</metadata>\n";
+    }
+
+    private String pomFileContents() {
+        return
+                "<?xml version='1.0' encoding='UTF-8'?>\n" +
+                        "<project xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://maven.apache.org/POM/4.0.0\">\n" +
+                        "    <modelVersion>4.0.0</modelVersion>\n" +
+                        "    <groupId>com.example</groupId>\n" +
+                        "    <artifactId>artifact</artifactId>\n" +
+                        "    <packaging>jar</packaging>\n" +
+                        "    <description>artifact</description>\n" +
+                        "    <version>1.0.0</version>\n" +
+                        "    <name>artifact</name>\n" +
+                        "    <organization>\n" +
+                        "        <name>com.example</name>\n" +
+                        "    </organization>\n" +
+                        "    <properties>\n" +
+                        "        <info.versionScheme>early-semver</info.versionScheme>\n" +
+                        "    </properties>\n" +
+                        "    <dependencies>\n" +
+                        "        <dependency>\n" +
+                        "            <groupId>com.example</groupId>\n" +
+                        "            <artifactId>dependency</artifactId>\n" +
+                        "            <version>1.0.0</version>\n" +
+                        "        </dependency>\n" +
+                        "    </dependencies>\n" +
+                        "</project>";
+    }
+}
diff --git a/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverMultipleReposTest.java b/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverMultipleReposTest.java
index 87a6eda4a..e951546c0 100644
--- a/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverMultipleReposTest.java
+++ b/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverMultipleReposTest.java
@@ -2,6 +2,7 @@ package software.amazon.smithy.cli;
 
 import org.apache.commons.codec.digest.DigestUtils;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
 import org.mockserver.integration.ClientAndServer;
 import org.mockserver.model.HttpRequest;
 import org.mockserver.model.HttpResponse;
@@ -14,6 +15,7 @@ import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.containsString;
 import static org.mockserver.integration.ClientAndServer.startClientAndServer;
 
+@Isolated
 public class MavenResolverMultipleReposTest {
 
     @Test
@@ -57,13 +59,13 @@ public class MavenResolverMultipleReposTest {
                         assertThat(result.getOutput(), containsString("software.amazon.smithy.cli.dependencies.DependencyResolver - Resolved Maven dependencies: [com.example:artifact:jar:1.0.0-20230724.184336-2"));
                     });
         } finally {
-            if(mockServer!=null) {
+            if (mockServer!=null) {
                 mockServer.stop();
             }
         }
     }
 
-    private void mockArtifactAndSha(ClientAndServer mockServer, String path, String contents) {
+    public static void mockArtifactAndSha(ClientAndServer mockServer, String path, String contents) {
         mockSuccess(
             mockServer,
             path,
diff --git a/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverTest.java b/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverTest.java
index add3af72e..d856d7e62 100644
--- a/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverTest.java
+++ b/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverTest.java
@@ -101,7 +101,7 @@ public class MavenResolverTest {
                         assertThat(result.getOutput(), containsString("HttpAuthenticator - Authentication succeeded"));
                     });
         } finally {
-            if(mockServer!=null) {
+            if (mockServer!=null) {
                 mockServer.stop();
             }
         }
diff --git a/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/ConfigurationUtilsTest.java b/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/ConfigurationUtilsTest.java
new file mode 100644
index 000000000..99daab6ab
--- /dev/null
+++ b/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/ConfigurationUtilsTest.java
@@ -0,0 +1,48 @@
+package software.amazon.smithy.cli.commands;
+
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.build.model.MavenConfig;
+import software.amazon.smithy.build.model.MavenRepository;
+import software.amazon.smithy.build.model.SmithyBuildConfig;
+import software.amazon.smithy.utils.ListUtils;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class ConfigurationUtilsTest {
+    private static final MavenRepository CENTRAL = MavenRepository.builder()
+            .url("https://repo.maven.apache.org/maven2")
+            .build();
+
+    private static final MavenRepository TEST_REPO_1 = MavenRepository.builder()
+            .url("fake_fake_fake")
+            .build();
+    private static final MavenRepository TEST_REPO_2 = MavenRepository.builder()
+            .url("not_real")
+            .build();
+
+    @Test
+    public void returnsDefaultRepository() {
+        SmithyBuildConfig emptyConfig = SmithyBuildConfig.builder().version("1.0").build();
+        Set<MavenRepository> repositorySet = ConfigurationUtils.getConfiguredMavenRepos(emptyConfig);
+
+        assertEquals(repositorySet.size(), 1);
+        assertThat(repositorySet, contains(CENTRAL));
+    }
+
+    @Test
+    public void returnsCorrectRepositoriesInConfig() {
+        SmithyBuildConfig config = SmithyBuildConfig.builder()
+                .version("1.0")
+                .maven(MavenConfig.builder()
+                        .repositories(ListUtils.of(TEST_REPO_1, TEST_REPO_2))
+                        .build())
+                .build();
+        Set<MavenRepository> repositorySet = ConfigurationUtils.getConfiguredMavenRepos(config);
+
+        assertEquals(repositorySet.size(), 2);
+        assertThat(repositorySet, contains(TEST_REPO_1, TEST_REPO_2));
+    }
+}
diff --git a/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/LockFileTest.java b/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/LockFileTest.java
new file mode 100644
index 000000000..99a0dc7ac
--- /dev/null
+++ b/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/LockFileTest.java
@@ -0,0 +1,109 @@
+package software.amazon.smithy.cli.commands;
+
+import java.io.File;
+import java.net.URISyntaxException;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.cli.CliError;
+import software.amazon.smithy.cli.dependencies.ResolvedArtifact;
+import software.amazon.smithy.model.node.ArrayNode;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.utils.ListUtils;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class LockFileTest {
+    @Test
+    public void hasNoDefaultsBuiltInToThePojo() {
+        LockFile lock = LockFile.builder().build();
+
+        assertThat(lock.getDependencyCoordinateSet(), empty());
+        assertThat(lock.getRepositories(), empty());
+    }
+
+    @Test
+    public void loadsEmptyLockfile() {
+        LockFile lock = LockFile.fromNode(Node.objectNode());
+
+        assertThat(lock.getDependencyCoordinateSet(), empty());
+        assertThat(lock.getRepositories(), empty());
+    }
+
+    @Test
+    public void loadsFromNode() {
+        LockFile lock = LockFile.fromNode(getNode());
+
+        assertThat(lock.getVersion(), equalTo("1.0"));
+        assertThat(lock.getConfigHash(), equalTo(-1856284556));
+        assertThat(lock.getRepositories(), hasSize(1));
+        assertThat(lock.getRepositories(), contains("repo"));
+        assertThat(lock.getDependencyCoordinateSet(), hasSize(1));
+        assertThat(lock.getDependencyCoordinateSet(), contains("software.amazon.smithy:smithy-aws-traits:1.37.0"));
+    }
+
+    @Test
+    public void validatesResolved() {
+        LockFile lock = LockFile.fromNode(getNode());
+        List<ResolvedArtifact> artifactList = ListUtils.of(
+                ResolvedArtifact.fromCoordinateNode("software.amazon.smithy:smithy-aws-traits:1.37.0",
+                        Node.objectNodeBuilder()
+                                .withMember("path", "/a")
+                                .withMember("sha1", "sum")
+                                .build()));
+        lock.validateArtifacts(artifactList);
+    }
+
+    @Test
+    public void throwsErrorOnIncompatibleDependencies() {
+        LockFile lock = LockFile.fromNode(getNode());
+        List<ResolvedArtifact> artifactList = ListUtils.of(
+                ResolvedArtifact.fromCoordinateNode("software.amazon.smithy:smithy-aws-traits:1.37.0",
+                        Node.objectNodeBuilder()
+                        .withMember("path", "/a")
+                        .withMember("sha1", "badSum")
+                        .build()));
+        Assertions.assertThrows(CliError.class, () -> lock.validateArtifacts(artifactList));
+    }
+
+    @Test
+    public void loadsSmithyLockfile() throws URISyntaxException {
+        File lockfileResource = new File(
+                Objects.requireNonNull(getClass().getResource("smithy-lock-test.json")).toURI());
+        Optional<LockFile> lockFileOptional = LockFile.load(lockfileResource.toPath());
+
+        assertTrue(lockFileOptional.isPresent());
+        LockFile lockFile = lockFileOptional.get();
+        assertEquals(lockFile.getConfigHash(), -1856284556);
+        assertEquals(lockFile.getVersion(), "1.0");
+        assertThat(lockFile.getDependencyCoordinateSet(), contains("software.amazon.smithy:smithy-aws-traits:1.37.0"));
+        assertThat(lockFile.getRepositories(), contains("https://repo.maven.apache.org/maven2"));
+    }
+
+    @Test
+    public void returnsEmptyWhenNoLockfile() {
+        Optional<LockFile> lockFileOptional = LockFile.load();
+        assertFalse(lockFileOptional.isPresent());
+    }
+
+
+    private Node getNode() {
+        return Node.objectNodeBuilder()
+                .withMember("version", "1.0")
+                .withMember("configHash", -1856284556)
+                .withMember("repositories", ArrayNode.fromNodes(Node.from("repo")))
+                .withMember("artifacts", Node.objectNode()
+                        .withMember("software.amazon.smithy:smithy-aws-traits:1.37.0",
+                                        Node.objectNode().withMember("sha1", "sum")))
+                .build();
+    }
+}
diff --git a/smithy-cli/src/test/java/software/amazon/smithy/cli/dependencies/FilterCliVersionResolverTest.java b/smithy-cli/src/test/java/software/amazon/smithy/cli/dependencies/FilterCliVersionResolverTest.java
index 264ed7e95..eaba5a5ff 100644
--- a/smithy-cli/src/test/java/software/amazon/smithy/cli/dependencies/FilterCliVersionResolverTest.java
+++ b/smithy-cli/src/test/java/software/amazon/smithy/cli/dependencies/FilterCliVersionResolverTest.java
@@ -4,7 +4,6 @@ import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.empty;
 
-import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
diff --git a/smithy-cli/src/test/resources/software/amazon/smithy/cli/commands/smithy-lock-test.json b/smithy-cli/src/test/resources/software/amazon/smithy/cli/commands/smithy-lock-test.json
new file mode 100644
index 000000000..e5e438f39
--- /dev/null
+++ b/smithy-cli/src/test/resources/software/amazon/smithy/cli/commands/smithy-lock-test.json
@@ -0,0 +1,11 @@
+{
+  "AUTOGENERATED_DO_NOT_MODIFY":"",
+  "version":"1.0",
+  "configHash":-1856284556,
+  "repositories":["https://repo.maven.apache.org/maven2"],
+  "artifacts":{
+    "software.amazon.smithy:smithy-aws-traits:1.37.0":{
+      "sha1":"723b00f4473c0f3f308b57b2c95b32fad669904c68d8e287606304d841cd52ff"
+    }
+  }
+}
diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/transform/DeconflictErrorsWithSharedStatusCodeTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/transform/DeconflictErrorsWithSharedStatusCodeTest.java
new file mode 100644
index 000000000..932d90dd6
--- /dev/null
+++ b/smithy-model/src/test/java/software/amazon/smithy/model/transform/DeconflictErrorsWithSharedStatusCodeTest.java
@@ -0,0 +1,48 @@
+package software.amazon.smithy.model.transform;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.ModelSerializer;
+import software.amazon.smithy.model.shapes.ServiceShape;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+public class DeconflictErrorsWithSharedStatusCodeTest {
+    @Test
+    public void deconflictErrorsWithSharedStatusCodes() {
+        Model input = Model.assembler()
+                .addImport(getClass().getResource("conflicting-errors.smithy"))
+                .assemble()
+                .unwrap();
+        Model output = Model.assembler()
+                .addImport(getClass().getResource("deconflicted-errors.smithy"))
+                .assemble()
+                .unwrap();
+
+        ModelTransformer transformer = ModelTransformer.create();
+
+        ServiceShape service = input.expectShape(ShapeId.from("smithy.example#MyService"), ServiceShape.class);
+
+        Model result = transformer.deconflictErrorsWithSharedStatusCode(input, service);
+
+        Node actual = ModelSerializer.builder().build().serialize(result);
+        Node expected = ModelSerializer.builder().build().serialize(output);
+        Node.assertEquals(actual, expected);
+    }
+
+    @Test
+    public void throwsWhenHeadersConflict() {
+        Model model = Model.assembler()
+                .addImport(getClass().getResource("conflicting-errors-with-conflicting-headers.smithy"))
+                .assemble()
+                .unwrap();
+
+        ModelTransformer transformer = ModelTransformer.create();
+
+        ServiceShape service = model.expectShape(ShapeId.from("smithy.example#MyService"), ServiceShape.class);
+        assertThrows(ModelTransformException.class,
+                () -> transformer.deconflictErrorsWithSharedStatusCode(model, service));
+    }
+}
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/detects-default-out-of-range.errors b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/detects-default-out-of-range.errors
index e57de3671..9a98f25f2 100644
--- a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/detects-default-out-of-range.errors
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/detects-default-out-of-range.errors
@@ -1,10 +1,10 @@
-[WARNING] smithy.example#Integers$invalidMin: Error validating @default trait: Value provided for `smithy.example#Integers$invalidMin` must be greater than or equal to 1, but found 0 | DefaultTrait
-[WARNING] smithy.example#Integers$invalidMax: Error validating @default trait: Value provided for `smithy.example#Integers$invalidMax` must be less than or equal to -1, but found 0 | DefaultTrait
-[WARNING] smithy.example#Integers$invalidTargetMin: Error validating @default trait: Value provided for `smithy.example#MinOne` must be greater than or equal to 1, but found 0 | DefaultTrait
-[WARNING] smithy.example#Integers$invalidTagetMax: Error validating @default trait: Value provided for `smithy.example#MaxNegativeOne` must be less than or equal to -1, but found 0 | DefaultTrait
-[WARNING] smithy.example#Integers$invalidMinWithMax: Error validating @default trait: Value provided for `smithy.example#MinAndMax` must be greater than or equal to 1, but found 0 | DefaultTrait
-[WARNING] smithy.example#MinOne: Error validating @default trait: Value provided for `smithy.example#MinOne` must be greater than or equal to 1, but found 0 | DefaultTrait
-[WARNING] smithy.example#MinAndMax: Error validating @default trait: Value provided for `smithy.example#MinAndMax` must be greater than or equal to 1, but found 0 | DefaultTrait
-[WARNING] smithy.example#MaxNegativeOne: Error validating @default trait: Value provided for `smithy.example#MaxNegativeOne` must be less than or equal to -1, but found 0 | DefaultTrait
-[WARNING] smithy.example#Integers$doublyInvalidDefault: Error validating @default trait: Value provided for `smithy.example#Integers$doublyInvalidDefault` must be greater than or equal to 1, but found 0 | DefaultTrait.Member
-[WARNING] smithy.example#Integers$doublyInvalidDefault: Error validating @default trait: Value provided for `smithy.example#MinOne` must be greater than or equal to 1, but found 0 | DefaultTrait.Target
+[WARNING] smithy.example#Integers$invalidMin: Error validating @default trait: Value provided for `smithy.example#Integers$invalidMin` must be greater than or equal to 1, but found 0 | DefaultTrait.Member.InvalidRange
+[WARNING] smithy.example#Integers$invalidMax: Error validating @default trait: Value provided for `smithy.example#Integers$invalidMax` must be less than or equal to -1, but found 0 | DefaultTrait.Member.InvalidRange
+[WARNING] smithy.example#Integers$invalidTargetMin: Error validating @default trait: Value provided for `smithy.example#MinOne` must be greater than or equal to 1, but found 0 | DefaultTrait.Target.InvalidRange
+[WARNING] smithy.example#Integers$invalidTargetMax: Error validating @default trait: Value provided for `smithy.example#MaxNegativeOne` must be less than or equal to -1, but found 0 | DefaultTrait.Target.InvalidRange
+[WARNING] smithy.example#Integers$invalidMinWithMax: Error validating @default trait: Value provided for `smithy.example#MinAndMax` must be greater than or equal to 1, but found 0 | DefaultTrait.Target.InvalidRange
+[WARNING] smithy.example#MinOne: Error validating @default trait: Value provided for `smithy.example#MinOne` must be greater than or equal to 1, but found 0 | DefaultTrait.Target.InvalidRange
+[WARNING] smithy.example#MinAndMax: Error validating @default trait: Value provided for `smithy.example#MinAndMax` must be greater than or equal to 1, but found 0 | DefaultTrait.Target.InvalidRange
+[WARNING] smithy.example#MaxNegativeOne: Error validating @default trait: Value provided for `smithy.example#MaxNegativeOne` must be less than or equal to -1, but found 0 | DefaultTrait.Target.InvalidRange
+[WARNING] smithy.example#Integers$doublyInvalidDefault: Error validating @default trait: Value provided for `smithy.example#Integers$doublyInvalidDefault` must be greater than or equal to 1, but found 0 | DefaultTrait.Member.InvalidRange
+[WARNING] smithy.example#Integers$doublyInvalidDefault: Error validating @default trait: Value provided for `smithy.example#MinOne` must be greater than or equal to 1, but found 0 | DefaultTrait.Target.InvalidRange
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/detects-default-out-of-range.smithy b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/detects-default-out-of-range.smithy
index ebae7fa29..20beb5749 100644
--- a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/detects-default-out-of-range.smithy
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/detects-default-out-of-range.smithy
@@ -22,7 +22,7 @@ structure Integers {
     invalidTargetMin: MinOne,
 
     @default(0)
-    invalidTagetMax: MaxNegativeOne,
+    invalidTargetMax: MaxNegativeOne,
 
     @default(0)
     invalidMinWithMax: MinAndMax,
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/numbers-must-match-constraints.errors b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/numbers-must-match-constraints.errors
index ee42f28e2..d953db709 100644
--- a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/numbers-must-match-constraints.errors
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/defaults/numbers-must-match-constraints.errors
@@ -1,1 +1,1 @@
-[WARNING] smithy.example#Foo$baz: Error validating @default trait: Value provided for `smithy.example#Baz` must be greater than or equal to 1, but found 0 | DefaultTrait
+[WARNING] smithy.example#Foo$baz: Error validating @default trait: Value provided for `smithy.example#Baz` must be greater than or equal to 1, but found 0 | DefaultTrait.Target.InvalidRange
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/transform/conflicting-errors-with-conflicting-headers.smithy b/smithy-model/src/test/resources/software/amazon/smithy/model/transform/conflicting-errors-with-conflicting-headers.smithy
new file mode 100644
index 000000000..e561ac927
--- /dev/null
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/transform/conflicting-errors-with-conflicting-headers.smithy
@@ -0,0 +1,28 @@
+$version: "2.0"
+
+namespace smithy.example
+
+service MyService {
+    operations: [MyOperation]
+}
+
+operation MyOperation {
+    input := {
+        string: String
+    }
+    errors: [FooError, BarError]
+}
+
+@error("client")
+@httpError(429)
+structure FooError {
+    @httpHeader("x-foo")
+    xFoo: String
+}
+
+@error("client")
+@httpError(429)
+structure BarError {
+    @httpHeader("x-bar")
+    xFoo: String
+}
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/transform/conflicting-errors.smithy b/smithy-model/src/test/resources/software/amazon/smithy/model/transform/conflicting-errors.smithy
new file mode 100644
index 000000000..f7475e200
--- /dev/null
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/transform/conflicting-errors.smithy
@@ -0,0 +1,55 @@
+$version: "2.0"
+
+namespace smithy.example
+
+service MyService {
+    operations: [MyOperation]
+    errors: [FooServiceLevelError, BarServiceLevelError]
+}
+
+operation MyOperation {
+    input := {
+        string: String
+    }
+    errors: [FooError, BarError]
+}
+
+@error("client")
+@httpError(429)
+structure FooServiceLevelError {
+    @httpHeader("x-service-foo")
+    xServiceFoo: String
+
+    @httpHeader("x-common")
+    xCommon: String
+}
+
+@error("client")
+@httpError(429)
+structure BarServiceLevelError {
+    @httpHeader("x-service-bar")
+    xServiceBar: String
+
+    @httpHeader("x-common")
+    xCommon: String
+}
+
+@error("client")
+@httpError(429)
+structure FooError {
+    @httpHeader("x-foo")
+    xFoo: String
+
+    @httpHeader("x-common")
+    xCommon: String
+}
+
+@error("client")
+@httpError(429)
+structure BarError {
+    @httpHeader("x-bar")
+    xBar: String
+
+    @httpHeader("x-common")
+    xCommon: String
+}
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/transform/deconflicted-errors.smithy b/smithy-model/src/test/resources/software/amazon/smithy/model/transform/deconflicted-errors.smithy
new file mode 100644
index 000000000..7dd69395f
--- /dev/null
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/transform/deconflicted-errors.smithy
@@ -0,0 +1,54 @@
+$version: "2.0"
+
+namespace smithy.example
+
+service MyService {
+    operations: [MyOperation]
+    errors: [FooServiceLevelError, BarServiceLevelError]
+}
+
+operation MyOperation {
+    input := {
+        string: String
+    }
+    errors: [MyOperation429Error]
+}
+
+@error("client")
+@httpError(429)
+structure MyOperation429Error {
+    errorUnion: MyOperation429ErrorContent
+    @httpHeader("x-foo")
+    xFoo: String
+    @httpHeader("x-bar")
+    xBar: String
+    @httpHeader("x-common")
+    xCommon: String
+    @httpHeader("x-service-foo")
+    xServiceFoo: String
+    @httpHeader("x-service-bar")
+    xServiceBar: String
+}
+
+union MyOperation429ErrorContent {
+    FooError: FooError
+    BarError: BarError
+    FooServiceLevelError: FooServiceLevelError
+    BarServiceLevelError: BarServiceLevelError
+}
+
+@error("client")
+@httpError(429)
+structure FooServiceLevelError {}
+
+@error("client")
+@httpError(429)
+structure BarServiceLevelError {}
+
+@error("client")
+@httpError(429)
+structure FooError {}
+
+@error("client")
+@httpError(429)
+structure BarError {}
diff --git a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1ProtocolTest.java b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1ProtocolTest.java
index 0e31bb7c5..4b588f984 100644
--- a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1ProtocolTest.java
+++ b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1ProtocolTest.java
@@ -288,4 +288,29 @@ public class AwsRestJson1ProtocolTest {
             Node.assertEquals(result, expectedNode);
         }
     }
+
+    @Test
+    public void combinesErrorsWithSameStatusCode() {
+        Model model = Model.assembler()
+                .addImport(getClass().getResource("error-code-collision-test.smithy"))
+                .discoverModels()
+                .assemble()
+                .unwrap();
+        OpenApiConfig config = new OpenApiConfig();
+        config.setService(ShapeId.from("example#Example"));
+        config.setOnErrorStatusConflict(OpenApiConfig.ErrorStatusConflictHandlingStrategy.ONE_OF);
+        ObjectNode result = OpenApiConverter.create()
+                .config(config)
+                .convertToNode(model);
+        InputStream openApiStream = getClass()
+                .getResourceAsStream("error-code-collision-test-use-oneof.openapi.json");
+
+        if (openApiStream == null) {
+            throw new RuntimeException("OpenAPI model not found for test case: "
+                    + "error-code-collision-test-use-properties.openapi.json");
+        } else {
+            Node expectedNode = Node.parse(IoUtils.toUtf8String(openApiStream));
+            Node.assertEquals(result, expectedNode);
+        }
+    }
 }
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/error-code-collision-test-use-oneof.openapi.json b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/error-code-collision-test-use-oneof.openapi.json
new file mode 100644
index 000000000..4c85edc69
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/error-code-collision-test-use-oneof.openapi.json
@@ -0,0 +1,272 @@
+{
+  "openapi": "3.0.2",
+  "info": {
+    "title": "Example",
+    "version": "2006-03-01"
+  },
+  "paths": {
+    "/time": {
+      "get": {
+        "operationId": "GetCurrentTime",
+        "responses": {
+          "200": {
+            "description": "GetCurrentTime 200 response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/GetCurrentTimeResponseContent"
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "GetCurrentTime404Error 404 response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/GetCurrentTime404ErrorResponseContent"
+                }
+              }
+            }
+          },
+          "429": {
+            "description": "GetCurrentTime429Error 429 response",
+            "headers": {
+              "error1-header": {
+                "schema": {
+                  "type": "string"
+                }
+              },
+              "error2-header": {
+                "schema": {
+                  "type": "string"
+                }
+              }
+            },
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/GetCurrentTime429ErrorResponseContent"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "Error1": {
+        "type": "object",
+        "properties": {
+          "message": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "message"
+        ]
+      },
+      "Error2": {
+        "type": "object",
+        "properties": {
+          "message": {
+            "type": "string"
+          },
+          "message2": {
+            "type": "string"
+          },
+          "message3": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "message"
+        ]
+      },
+      "Error3": {
+        "type": "object",
+        "properties": {
+          "message24": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "message24"
+        ]
+      },
+      "Error4": {
+        "type": "object",
+        "properties": {
+          "message365": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "message365"
+        ]
+      },
+      "Error5": {
+        "type": "object",
+        "properties": {
+          "message": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "message"
+        ]
+      },
+      "Error6": {
+        "type": "object",
+        "properties": {
+          "message": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "message"
+        ]
+      },
+      "Error7": {
+        "type": "object",
+        "properties": {
+          "message": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "message"
+        ]
+      },
+      "Error8": {
+        "type": "object",
+        "properties": {
+          "message2": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "message2"
+        ]
+      },
+      "GetCurrentTime404ErrorResponseContent": {
+        "oneOf": [
+          {
+            "type": "object",
+            "title": "Error7",
+            "properties": {
+              "Error7": {
+                "$ref": "#/components/schemas/Error7"
+              }
+            },
+            "required": [
+              "Error7"
+            ]
+          },
+          {
+            "type": "object",
+            "title": "Error8",
+            "properties": {
+              "Error8": {
+                "$ref": "#/components/schemas/Error8"
+              }
+            },
+            "required": [
+              "Error8"
+            ]
+          }
+        ]
+      },
+      "GetCurrentTime429ErrorResponseContent": {
+        "oneOf": [
+          {
+            "type": "object",
+            "title": "Error1",
+            "properties": {
+              "Error1": {
+                "$ref": "#/components/schemas/Error1"
+              }
+            },
+            "required": [
+              "Error1"
+            ]
+          },
+          {
+            "type": "object",
+            "title": "Error2",
+            "properties": {
+              "Error2": {
+                "$ref": "#/components/schemas/Error2"
+              }
+            },
+            "required": [
+              "Error2"
+            ]
+          },
+          {
+            "type": "object",
+            "title": "Error3",
+            "properties": {
+              "Error3": {
+                "$ref": "#/components/schemas/Error3"
+              }
+            },
+            "required": [
+              "Error3"
+            ]
+          },
+          {
+            "type": "object",
+            "title": "Error4",
+            "properties": {
+              "Error4": {
+                "$ref": "#/components/schemas/Error4"
+              }
+            },
+            "required": [
+              "Error4"
+            ]
+          },
+          {
+            "type": "object",
+            "title": "Error5",
+            "properties": {
+              "Error5": {
+                "$ref": "#/components/schemas/Error5"
+              }
+            },
+            "required": [
+              "Error5"
+            ]
+          },
+          {
+            "type": "object",
+            "title": "Error6",
+            "properties": {
+              "Error6": {
+                "$ref": "#/components/schemas/Error6"
+              }
+            },
+            "required": [
+              "Error6"
+            ]
+          }
+        ]
+      },
+      "GetCurrentTimeResponseContent": {
+        "type": "object",
+        "properties": {
+          "time": {
+            "type": "number",
+            "format": "double"
+          }
+        },
+        "required": [
+          "time"
+        ]
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/error-code-collision-test.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/error-code-collision-test.smithy
new file mode 100644
index 000000000..b20bec286
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/error-code-collision-test.smithy
@@ -0,0 +1,111 @@
+namespace example
+
+use aws.protocols#restJson1
+use smithy.framework#ValidationException
+
+@restJson1
+service Example {
+    version: "2006-03-01",
+    operations: [GetCurrentTime],
+    errors: [
+        Error1,
+        Error2,
+        Error3,
+        Error4,
+        Error5,
+        Error6,
+        Error7,
+        Error8
+    ]
+}
+
+@error("client")
+@retryable(throttling: true)
+@httpError(429) // Too many requests
+structure Error1 {
+    @httpHeader("error1-header")
+    @required
+    header: String,
+
+    @required
+    message: String,
+}
+
+@error("client")
+@retryable(throttling: true)
+@httpError(429) // Too many requests
+structure Error2 {
+    @httpHeader("error2-header")
+    @required
+    header2: String,
+
+    @required
+    message: String,
+
+    message2: String,
+
+    message3: String
+}
+
+@error("client")
+@retryable(throttling: true)
+@httpError(429) // Too many requests
+structure Error3 {
+    @required
+    message24 : String,
+}
+
+@error("client")
+@retryable(throttling: true)
+@httpError(429) // Too many requests
+structure Error4 {
+    @required
+    message365: String,
+}
+
+@error("client")
+@retryable(throttling: true)
+@httpError(429) // Too many requests
+structure Error5 {
+    @required
+    message: String,
+}
+
+@error("client")
+@retryable(throttling: true)
+@httpError(429) // Too many requests
+structure Error6 {
+    @required
+    message: String,
+}
+
+@error("client")
+@httpError(404) // Too many requests
+structure Error7 {
+    @required
+    message: String,
+}
+
+@error("client")
+@httpError(404) // Too many requests
+structure Error8 {
+    @required
+    message2: String,
+}
+
+
+@readonly
+@http(uri: "/time", method: "GET")
+operation GetCurrentTime {
+    input: GetCurrentTimeInput,
+    output: GetCurrentTimeOutput
+}
+
+@input
+structure GetCurrentTimeInput {}
+
+@output
+structure GetCurrentTimeOutput {
+    @required
+    time: Timestamp
+}