Repository: dbt-labs/dbt-core
Pull request: Detect breaking changes to column names and data types in state:modified check
resolves #6869


### Description

Add "same_contract", "state:modified.contract" to defer_state checks, create new exception and tests.

### Checklist

- [x] I have read [the contributing guide](https://github.com/dbt-labs/dbt-core/blob/main/CONTRIBUTING.md) and understand what's expected of me
- [x] I have signed the [CLA](https://docs.getdbt.com/docs/contributor-license-agreements)
- [x] I have run this code in development and it appears to resolve the stated issue
- [x] This PR includes tests, or tests are not required/relevant for this PR
- [x] I have [opened an issue to add/update docs](https://github.com/dbt-labs/docs.getdbt.com/issues/new/choose), or docs changes are not required/relevant for this PR
- [x] I have run `changie new` to [create a changelog entry](https://github.com/dbt-labs/dbt-core/blob/main/CONTRIBUTING.md#adding-a-changelog-entry)


Problem statement:
[CT-2038] Detect breaking changes to column names and data types in state:modified check
If a model with `contract:true` has its column name/data types modified in a way that represents a breaking change, dbt should raise an error as part of the `state:modified` check. 

What constitutes a breaking change?
- Removal of column
- name change of column
- data_type change of column 
- changing `contract:true` to `contract:false`

Hints from issue discussion:
Do we want this exception to be limited to commands where the user has explicitly selected `state:modified`? Or as soon as [previous state](https://github.com/dbt-labs/dbt-core/blob/b9fdfd9e3641e38ff4c96d3b20c5773e1ff50d54/core/dbt/task/runnable.py#L117-L124) is established? Although it feels a bit odd, I think I'm okay with raising it only when `state:modified` is selected.

We could also add [a new sub-selector, `state:modified.contracts`](https://docs.getdbt.com/reference/node-selection/methods#the-state-method).

Implementation detail: We have a number of `same_*` methods ([example](https://github.com/dbt-labs/dbt-core/blob/b9fdfd9e3641e38ff4c96d3b20c5773e1ff50d54/core/dbt/contracts/graph/nodes.py#L364-L375)) which power `state:modified` selection today. It could make sense to define a `same_contract` method, with these return values:
- `True` if the contract is the same (including models without contracts)
- `False` is the contract has changed, in a non-breaking way
- Raises an exception if the contract has changed _in a breaking way_
error message should direct users to create a new version and point to versioning docs: https://docs.getdbt.com/docs/collaborate/publish/model-versions
What about order? If the order of the columns changes should it be considered modified or not?
If contracts are set to false in both old and new states, then I assume that even if the the columns names or data_types change, that is not a breaking change?
> What about order? If the order of the columns changes should it be considered modified or not?

For now, we're going to say that column order doesn't matter. We might add this as a configuration option later on if lots of users request it.

> If contracts are set to false in both old and new states, then I assume that even if the the columns names or data_types change, that is not a breaking change?

Correct! Adopting the terminology / data structure proposed in [#7184:](https://github.com/dbt-labs/dbt-core/issues/7184#issuecomment-1475792952)
- If old `contract: {enforced: false}` + new `contract: {enforced: false}` &rarr; never a breaking change
- If old `contract: {enforced: false}` + new `contract: {enforced: true}` &rarr; never a breaking change
- If old `contract: {enforced: true}` + new `contract: {enforced: true}` &rarr; detect breaking changes, following the logic in the description
- If old `contract: {enforced: true}` + new `contract: {enforced: false}` &rarr; always a breaking change!
Currently when old has contract false and new has contract true, it will show as modified because the config has changed: unrendered_config = {'contract': True, 'materialized': 'table'}
Yes! It's definitely a change, and the model should be selected to run by `state:modified` / in CI. It's just not a _breaking_ change, so it shouldn't raise an error.

Commit message: Update artifacts tests. Add reason to exception.

Patch to review:
diff --git a/.changes/unreleased/Features-20230323-133026.yaml b/.changes/unreleased/Features-20230323-133026.yaml
new file mode 100644
index 00000000000..67e3297f7d5
--- /dev/null
+++ b/.changes/unreleased/Features-20230323-133026.yaml
@@ -0,0 +1,6 @@
+kind: Features
+body: Detect breaking changes to contracts in state:modified check
+time: 2023-03-23T13:30:26.593717-04:00
+custom:
+  Author: gshank
+  Issue: "6869"
diff --git a/core/dbt/contracts/graph/nodes.py b/core/dbt/contracts/graph/nodes.py
index 017f372adb8..dcabb4ea98c 100644
--- a/core/dbt/contracts/graph/nodes.py
+++ b/core/dbt/contracts/graph/nodes.py
@@ -2,6 +2,7 @@
 import time
 from dataclasses import dataclass, field
 from enum import Enum
+import hashlib
 
 from mashumaro.types import SerializableType
 from typing import (
@@ -39,7 +40,7 @@
 from dbt.contracts.util import Replaceable, AdditionalPropertiesMixin
 from dbt.events.proto_types import NodeInfo
 from dbt.events.functions import warn_or_error
-from dbt.exceptions import ParsingError, InvalidAccessTypeError
+from dbt.exceptions import ParsingError, InvalidAccessTypeError, ModelContractError
 from dbt.events.types import (
     SeedIncreased,
     SeedExceedsLimitSamePath,
@@ -387,6 +388,13 @@ def same_config(self, old) -> bool:
             old.unrendered_config,
         )
 
+    def build_contract_checksum(self):
+        pass
+
+    def same_contract(self, old) -> bool:
+        # This would only apply to seeds
+        return True
+
     def patch(self, patch: "ParsedNodePatch"):
         """Given a ParsedNodePatch, add the new information to the node."""
         # explicitly pick out the parts to update so we don't inadvertently
@@ -428,6 +436,7 @@ def same_contents(self, old) -> bool:
             and self.same_persisted_description(old)
             and self.same_fqn(old)
             and self.same_database_representation(old)
+            and self.same_contract(old)
             and True
         )
 
@@ -457,6 +466,7 @@ class CompiledNode(ParsedNode):
     extra_ctes: List[InjectedCTE] = field(default_factory=list)
     _pre_injected_sql: Optional[str] = None
     contract: bool = False
+    contract_checksum: Optional[str] = None
 
     @property
     def empty(self):
@@ -497,6 +507,39 @@ def depends_on_nodes(self):
     def depends_on_macros(self):
         return self.depends_on.macros
 
+    def build_contract_checksum(self):
+        # We don't need to construct the checksum if the model does not
+        # have contract enabled, because it won't be used.
+        # This needs to be executed after contract config is set
+        if self.contract is True:
+            contract_state = ""
+            # We need to sort the columns so that order doesn't matter
+            # columns is a str: ColumnInfo dictionary
+            sorted_columns = sorted(self.columns.values(), key=lambda col: col.name)
+            for column in sorted_columns:
+                contract_state += f"|{column.name}"
+                contract_state += column.data_type
+                contract_state += str(column.constraints)
+            data = contract_state.encode("utf-8")
+            self.contract_checksum = hashlib.new("sha256", data).hexdigest()
+
+    def same_contract(self, old) -> bool:
+        if old.contract is False and self.contract is False:
+            # Not a change
+            return True
+        if old.contract is False and self.contract is True:
+            # A change, but not a breaking change
+            return False
+        if old.contract is True and self.contract is False:
+            # Breaking change: throw an error
+            raise (ModelContractError(reason="contract has been disabled", node=self))
+        if self.contract_checksum == old.contract_checksum:
+            # Breaking change: throw an error
+            raise (ModelContractError(reason="column definitions have changed", node=self))
+        else:
+            # No change
+            return False
+
 
 # ====================================
 # CompiledNode subclasses
diff --git a/core/dbt/exceptions.py b/core/dbt/exceptions.py
index d85a9443976..8d93c183728 100644
--- a/core/dbt/exceptions.py
+++ b/core/dbt/exceptions.py
@@ -209,6 +209,25 @@ def _fix_dupe_msg(self, path_1: str, path_2: str, name: str, type_name: str) ->
             )
 
 
+class ModelContractError(DbtRuntimeError):
+    CODE = 10016
+    MESSAGE = "Contract Error"
+
+    def __init__(self, reason, node=None):
+        self.reason = reason
+        super().__init__(self.message(), node)
+
+    @property
+    def type(self):
+        return "Contract"
+
+    def message(self):
+        return (
+            "There is a breaking change in the model contract because {self.reason}; "
+            "you may need to create a new version. See: https://docs.getdbt.com/docs/collaborate/publish/model-versions"
+        )
+
+
 class RecursionError(DbtRuntimeError):
     pass
 
diff --git a/core/dbt/graph/selector_methods.py b/core/dbt/graph/selector_methods.py
index 4e143ff3c14..af80698c1fe 100644
--- a/core/dbt/graph/selector_methods.py
+++ b/core/dbt/graph/selector_methods.py
@@ -542,6 +542,7 @@ def search(self, included_nodes: Set[UniqueId], selector: str) -> Iterator[Uniqu
             ),
             "modified.relation": self.check_modified_factory("same_database_representation"),
             "modified.macros": self.check_modified_macros,
+            "modified.contract": self.check_modified_factory("same_contract"),
         }
         if selector in state_checks:
             checker = state_checks[selector]
diff --git a/core/dbt/parser/base.py b/core/dbt/parser/base.py
index 13e925fe716..e4411b8b25d 100644
--- a/core/dbt/parser/base.py
+++ b/core/dbt/parser/base.py
@@ -314,6 +314,7 @@ def update_parsed_node_config(
         # compatibility with earlier node-only config.
         if config_dict.get("contract", False):
             parsed_node.contract = True
+            parsed_node.build_contract_checksum()
 
         # unrendered_config is used to compare the original database/schema/alias
         # values and to handle 'same_config' and 'same_contents' calls
diff --git a/schemas/dbt/manifest/v9.json b/schemas/dbt/manifest/v9.json
index de71e63e41b..b2f55e3a3ff 100644
--- a/schemas/dbt/manifest/v9.json
+++ b/schemas/dbt/manifest/v9.json
@@ -216,12 +216,12 @@
         },
         "dbt_version": {
           "type": "string",
-          "default": "1.5.0b1"
+          "default": "1.5.0b4"
         },
         "generated_at": {
           "type": "string",
           "format": "date-time",
-          "default": "2023-02-27T20:52:53.570061Z"
+          "default": "2023-03-23T19:25:03.305889Z"
         },
         "invocation_id": {
           "oneOf": [
@@ -232,7 +232,7 @@
               "type": "null"
             }
           ],
-          "default": "f578f819-6554-4f6f-8af4-635e35249cb2"
+          "default": "c8c79025-e488-4f7b-87d3-1608f5896a48"
         },
         "env": {
           "type": "object",
@@ -449,7 +449,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.5741172
+          "default": 1679599503.310295
         },
         "config_call_dict": {
           "type": "object",
@@ -548,10 +548,20 @@
         "contract": {
           "type": "boolean",
           "default": false
+        },
+        "contract_checksum": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
         }
       },
       "additionalProperties": false,
-      "description": "AnalysisNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False)"
+      "description": "AnalysisNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, contract_checksum: Optional[str] = None)"
     },
     "FileHash": {
       "type": "object",
@@ -816,35 +826,11 @@
           ]
         },
         "constraints": {
-          "oneOf": [
-            {
-              "type": "array",
-              "items": {
-                "type": "object",
-                "required": ["type"],
-                "properties": {
-                  "type": {
-                    "type": "string"
-                  },
-                  "name": {
-                    "type": "string"
-                  },
-                  "expression": {
-                    "type": "string"
-                  },
-                  "warn_unenforced": {
-                    "type": "boolean"
-                  },
-                  "warn_unsupported": {
-                    "type": "boolean"
-                  }
-                }
-              }
-            },
-            {
-              "type": "null"
-            }
-          ]
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/ColumnLevelConstraint"
+          },
+          "default": []
         },
         "quote": {
           "oneOf": [
@@ -867,6 +853,55 @@
       "additionalProperties": true,
       "description": "Used in all ManifestNodes and SourceDefinition"
     },
+    "ColumnLevelConstraint": {
+      "type": "object",
+      "required": [
+        "type"
+      ],
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "check",
+            "not_null",
+            "unique",
+            "primary_key",
+            "foreign_key",
+            "custom"
+          ]
+        },
+        "name": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "expression": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "warn_unenforced": {
+          "type": "boolean",
+          "default": true
+        },
+        "warn_unsupported": {
+          "type": "boolean",
+          "default": true
+        }
+      },
+      "additionalProperties": false,
+      "description": "ColumnLevelConstraint(type: dbt.contracts.graph.nodes.ConstraintType, name: Optional[str] = None, expression: Optional[str] = None, warn_unenforced: bool = True, warn_unsupported: bool = True)"
+    },
     "DependsOn": {
       "type": "object",
       "required": [],
@@ -1056,7 +1091,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.577135
+          "default": 1679599503.3133209
         },
         "config_call_dict": {
           "type": "object",
@@ -1155,10 +1190,20 @@
         "contract": {
           "type": "boolean",
           "default": false
+        },
+        "contract_checksum": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
         }
       },
       "additionalProperties": false,
-      "description": "SingularTestNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.TestConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False)"
+      "description": "SingularTestNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.TestConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, contract_checksum: Optional[str] = None)"
     },
     "TestConfig": {
       "type": "object",
@@ -1441,7 +1486,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.578816
+          "default": 1679599503.3153348
         },
         "config_call_dict": {
           "type": "object",
@@ -1541,6 +1586,16 @@
           "type": "boolean",
           "default": false
         },
+        "contract_checksum": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
         "index": {
           "oneOf": [
             {
@@ -1553,7 +1608,7 @@
         }
       },
       "additionalProperties": false,
-      "description": "HookNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, index: Optional[int] = None)"
+      "description": "HookNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, contract_checksum: Optional[str] = None, index: Optional[int] = None)"
     },
     "ModelNode": {
       "type": "object",
@@ -1714,7 +1769,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.580534
+          "default": 1679599503.317371
         },
         "config_call_dict": {
           "type": "object",
@@ -1814,6 +1869,16 @@
           "type": "boolean",
           "default": false
         },
+        "contract_checksum": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
         "access": {
           "type": "string",
           "enum": [
@@ -1825,7 +1890,7 @@
         }
       },
       "additionalProperties": false,
-      "description": "ModelNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, access: dbt.node_types.AccessType = <AccessType.Protected: 'protected'>)"
+      "description": "ModelNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, contract_checksum: Optional[str] = None, access: dbt.node_types.AccessType = <AccessType.Protected: 'protected'>)"
     },
     "RPCNode": {
       "type": "object",
@@ -1986,7 +2051,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.5821729
+          "default": 1679599503.319129
         },
         "config_call_dict": {
           "type": "object",
@@ -2085,10 +2150,20 @@
         "contract": {
           "type": "boolean",
           "default": false
+        },
+        "contract_checksum": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
         }
       },
       "additionalProperties": false,
-      "description": "RPCNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False)"
+      "description": "RPCNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, contract_checksum: Optional[str] = None)"
     },
     "SqlNode": {
       "type": "object",
@@ -2249,7 +2324,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.5838
+          "default": 1679599503.320838
         },
         "config_call_dict": {
           "type": "object",
@@ -2348,10 +2423,20 @@
         "contract": {
           "type": "boolean",
           "default": false
+        },
+        "contract_checksum": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
         }
       },
       "additionalProperties": false,
-      "description": "SqlNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False)"
+      "description": "SqlNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.NodeConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, contract_checksum: Optional[str] = None)"
     },
     "GenericTestNode": {
       "type": "object",
@@ -2507,7 +2592,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.5856462
+          "default": 1679599503.322816
         },
         "config_call_dict": {
           "type": "object",
@@ -2607,6 +2692,16 @@
           "type": "boolean",
           "default": false
         },
+        "contract_checksum": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
         "column_name": {
           "oneOf": [
             {
@@ -2639,7 +2734,7 @@
         }
       },
       "additionalProperties": false,
-      "description": "GenericTestNode(test_metadata: dbt.contracts.graph.nodes.TestMetadata, database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.TestConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, column_name: Optional[str] = None, file_key_name: Optional[str] = None, attached_node: Optional[str] = None)"
+      "description": "GenericTestNode(test_metadata: dbt.contracts.graph.nodes.TestMetadata, database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.TestConfig = <factory>, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, contract_checksum: Optional[str] = None, column_name: Optional[str] = None, file_key_name: Optional[str] = None, attached_node: Optional[str] = None)"
     },
     "TestMetadata": {
       "type": "object",
@@ -2802,7 +2897,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.588902
+          "default": 1679599503.326293
         },
         "config_call_dict": {
           "type": "object",
@@ -2901,10 +2996,20 @@
         "contract": {
           "type": "boolean",
           "default": false
+        },
+        "contract_checksum": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ]
         }
       },
       "additionalProperties": false,
-      "description": "SnapshotNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.SnapshotConfig, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False)"
+      "description": "SnapshotNode(database: Optional[str], schema: str, name: str, resource_type: dbt.node_types.NodeType, package_name: str, path: str, original_file_path: str, unique_id: str, fqn: List[str], alias: str, checksum: dbt.contracts.files.FileHash, config: dbt.contracts.graph.model_config.SnapshotConfig, _event_status: Dict[str, Any] = <factory>, tags: List[str] = <factory>, description: str = '', columns: Dict[str, dbt.contracts.graph.nodes.ColumnInfo] = <factory>, meta: Dict[str, Any] = <factory>, group: Optional[str] = None, docs: dbt.contracts.graph.unparsed.Docs = <factory>, patch_path: Optional[str] = None, build_path: Optional[str] = None, deferred: bool = False, unrendered_config: Dict[str, Any] = <factory>, created_at: float = <factory>, config_call_dict: Dict[str, Any] = <factory>, relation_name: Optional[str] = None, raw_code: str = '', language: str = 'sql', refs: List[List[str]] = <factory>, sources: List[List[str]] = <factory>, metrics: List[List[str]] = <factory>, depends_on: dbt.contracts.graph.nodes.DependsOn = <factory>, compiled_path: Optional[str] = None, compiled: bool = False, compiled_code: Optional[str] = None, extra_ctes_injected: bool = False, extra_ctes: List[dbt.contracts.graph.nodes.InjectedCTE] = <factory>, _pre_injected_sql: Optional[str] = None, contract: bool = False, contract_checksum: Optional[str] = None)"
     },
     "SnapshotConfig": {
       "type": "object",
@@ -3285,7 +3390,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.591784
+          "default": 1679599503.3293371
         },
         "config_call_dict": {
           "type": "object",
@@ -3685,7 +3790,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.594429
+          "default": 1679599503.3325849
         }
       },
       "additionalProperties": false,
@@ -3795,12 +3900,12 @@
         },
         "dbt_version": {
           "type": "string",
-          "default": "1.5.0b1"
+          "default": "1.5.0b4"
         },
         "generated_at": {
           "type": "string",
           "format": "date-time",
-          "default": "2023-02-27T20:52:53.564664Z"
+          "default": "2023-03-23T19:25:03.300282Z"
         },
         "invocation_id": {
           "oneOf": [
@@ -3811,7 +3916,7 @@
               "type": "null"
             }
           ],
-          "default": "f578f819-6554-4f6f-8af4-635e35249cb2"
+          "default": "c8c79025-e488-4f7b-87d3-1608f5896a48"
         },
         "env": {
           "type": "object",
@@ -3822,7 +3927,7 @@
         }
       },
       "additionalProperties": false,
-      "description": "FreshnessMetadata(dbt_schema_version: str = <factory>, dbt_version: str = '1.5.0b1', generated_at: datetime.datetime = <factory>, invocation_id: Optional[str] = <factory>, env: Dict[str, str] = <factory>)"
+      "description": "FreshnessMetadata(dbt_schema_version: str = <factory>, dbt_version: str = '1.5.0b4', generated_at: datetime.datetime = <factory>, invocation_id: Optional[str] = <factory>, env: Dict[str, str] = <factory>)"
     },
     "SourceFreshnessRuntimeError": {
       "type": "object",
@@ -4164,7 +4269,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.595732
+          "default": 1679599503.333353
         },
         "supported_languages": {
           "oneOf": [
@@ -4407,7 +4512,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.597582
+          "default": 1679599503.335144
         }
       },
       "additionalProperties": false,
@@ -4630,7 +4735,7 @@
         },
         "created_at": {
           "type": "number",
-          "default": 1677531173.599355
+          "default": 1679599503.336958
         },
         "group": {
           "oneOf": [
diff --git a/test/unit/test_manifest.py b/test/unit/test_manifest.py
index 1d6fd68de57..e063440bcf8 100644
--- a/test/unit/test_manifest.py
+++ b/test/unit/test_manifest.py
@@ -88,6 +88,7 @@
         "relation_name",
         "contract",
         "access",
+        "contract_checksum",
     }
 )
 
diff --git a/tests/functional/artifacts/expected_manifest.py b/tests/functional/artifacts/expected_manifest.py
index 4b2bfbfd3e3..d63df27dbba 100644
--- a/tests/functional/artifacts/expected_manifest.py
+++ b/tests/functional/artifacts/expected_manifest.py
@@ -323,6 +323,7 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                     },
                 },
                 "contract": False,
+                "contract_checksum": None,
                 "patch_path": "test://" + model_schema_yml_path,
                 "docs": {"node_color": None, "show": False},
                 "compiled": True,
@@ -412,6 +413,7 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                     },
                 },
                 "contract": False,
+                "contract_checksum": None,
                 "patch_path": "test://" + model_schema_yml_path,
                 "docs": {"node_color": None, "show": False},
                 "compiled": True,
@@ -552,6 +554,7 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                 "checksum": {"name": "none", "checksum": ""},
                 "unrendered_config": unrendered_test_config,
                 "contract": False,
+                "contract_checksum": None,
             },
             "snapshot.test.snapshot_seed": {
                 "alias": "snapshot_seed",
@@ -564,6 +567,7 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                 "compiled_code": ANY,
                 "config": snapshot_config,
                 "contract": False,
+                "contract_checksum": None,
                 "database": project.database,
                 "group": None,
                 "deferred": False,
@@ -613,6 +617,7 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                 "config": test_config,
                 "group": None,
                 "contract": False,
+                "contract_checksum": None,
                 "sources": [],
                 "depends_on": {
                     "macros": ["macro.test.test_nothing", "macro.dbt.get_where_subquery"],
@@ -666,6 +671,7 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                 "config": test_config,
                 "group": None,
                 "contract": False,
+                "contract_checksum": None,
                 "sources": [],
                 "depends_on": {
                     "macros": ["macro.dbt.test_unique", "macro.dbt.get_where_subquery"],
@@ -929,6 +935,7 @@ def expected_references_manifest(project):
                 "compiled": True,
                 "compiled_code": ANY,
                 "contract": False,
+                "contract_checksum": None,
                 "extra_ctes_injected": True,
                 "extra_ctes": [],
                 "checksum": checksum_file(ephemeral_copy_path),
@@ -962,6 +969,7 @@ def expected_references_manifest(project):
                 },
                 "config": get_rendered_model_config(materialized="table", group="test_group"),
                 "contract": False,
+                "contract_checksum": None,
                 "sources": [],
                 "depends_on": {"macros": [], "nodes": ["model.test.ephemeral_copy"]},
                 "deferred": False,
@@ -1024,6 +1032,7 @@ def expected_references_manifest(project):
                 },
                 "config": get_rendered_model_config(),
                 "contract": False,
+                "contract_checksum": None,
                 "database": project.database,
                 "depends_on": {"macros": [], "nodes": ["model.test.ephemeral_summary"]},
                 "deferred": False,
@@ -1141,6 +1150,7 @@ def expected_references_manifest(project):
                 "compiled_code": ANY,
                 "config": get_rendered_snapshot_config(target_schema=alternate_schema),
                 "contract": False,
+                "contract_checksum": None,
                 "database": model_database,
                 "deferred": False,
                 "depends_on": {"macros": [], "nodes": ["seed.test.seed"]},
diff --git a/tests/functional/defer_state/fixtures.py b/tests/functional/defer_state/fixtures.py
index 17f46f842d9..b2e5994da5d 100644
--- a/tests/functional/defer_state/fixtures.py
+++ b/tests/functional/defer_state/fixtures.py
@@ -50,6 +50,58 @@
       - name: name
 """
 
+contract_schema_yml = """
+version: 2
+models:
+  - name: view_model
+    columns:
+      - name: id
+        tests:
+          - unique:
+              severity: error
+          - not_null
+      - name: name
+  - name: table_model
+    config:
+      contract: true
+    columns:
+      - name: id
+        data_type: integer
+        tests:
+          - unique:
+              severity: error
+          - not_null
+      - name: name
+        data_type: text
+"""
+
+modified_contract_schema_yml = """
+version: 2
+models:
+  - name: view_model
+    columns:
+      - name: id
+        tests:
+          - unique:
+              severity: error
+          - not_null
+      - name: name
+  - name: table_model
+    config:
+      contract: true
+    columns:
+      - name: id
+        data_type: integer
+        tests:
+          - unique:
+              severity: error
+          - not_null
+      - name: name
+        data_type: text
+        constraints:
+          - type: not_null
+"""
+
 exposures_yml = """
 version: 2
 exposures:
diff --git a/tests/functional/defer_state/test_modified_state.py b/tests/functional/defer_state/test_modified_state.py
index 80e3d455da1..91d2dd4aef8 100644
--- a/tests/functional/defer_state/test_modified_state.py
+++ b/tests/functional/defer_state/test_modified_state.py
@@ -5,9 +5,9 @@
 
 import pytest
 
-from dbt.tests.util import run_dbt, update_config_file, write_file
+from dbt.tests.util import run_dbt, update_config_file, write_file, get_manifest
 
-from dbt.exceptions import CompilationError
+from dbt.exceptions import CompilationError, ModelContractError
 
 from tests.functional.defer_state.fixtures import (
     seed_csv,
@@ -18,6 +18,8 @@
     exposures_yml,
     macros_sql,
     infinite_macros_sql,
+    contract_schema_yml,
+    modified_contract_schema_yml,
 )
 
 
@@ -261,3 +263,38 @@ def test_changed_exposure(self, project):
         results = run_dbt(["run", "--models", "+state:modified", "--state", "./state"])
         assert len(results) == 1
         assert results[0].node.name == "view_model"
+
+
+class TestChangedContract(BaseModifiedState):
+    def test_changed_contract(self, project):
+        self.run_and_save_state()
+
+        # update contract for table_model
+        write_file(contract_schema_yml, "models", "schema.yml")
+
+        # This will find the table_model node modified both through a config change
+        # and by a non-breaking change to contract: true
+        results = run_dbt(["run", "--models", "state:modified", "--state", "./state"])
+        assert len(results) == 1
+        assert results[0].node.name == "table_model"
+        manifest = get_manifest(project.project_root)
+        model_unique_id = "model.test.table_model"
+        model = manifest.nodes[model_unique_id]
+        expected_unrendered_config = {"contract": True, "materialized": "table"}
+        assert model.unrendered_config == expected_unrendered_config
+
+        # Run it again with "state:modified:contract", still finds modified due to contract: true
+        results = run_dbt(["run", "--models", "state:modified.contract", "--state", "./state"])
+        assert len(results) == 1
+        # save a new state
+        self.copy_state()
+
+        # This should raise because a column has added a constraint
+        write_file(modified_contract_schema_yml, "models", "schema.yml")
+        with pytest.raises(ModelContractError):
+            results = run_dbt(["run", "--models", "state:modified.contract", "--state", "./state"])
+
+        # Go back to schema file without contract. Should raise an error.
+        write_file(schema_yml, "models", "schema.yml")
+        with pytest.raises(ModelContractError):
+            results = run_dbt(["run", "--models", "state:modified.contract", "--state", "./state"])


Code review comments:
diff_hunk: @@ -497,6 +507,39 @@ def depends_on_nodes(self):
     def depends_on_macros(self):
         return self.depends_on.macros
 
+    def build_contract_checksum(self):
+        # We don't need to construct the checksum if the model does not
+        # have contract enabled, because it won't be used.
+        # This needs to be executed after contract config is set
+        if self.contract is True:
+            contract_state = ""
+            # We need to sort the columns so that order doesn't matter
+            # columns is a str: ColumnInfo dictionary
+            sorted_columns = sorted(self.columns.values(), key=lambda col: col.name)
+            for column in sorted_columns:
+                contract_state += f"|{column.name}"
+                contract_state += column.data_type
+                contract_state += str(column.constraints)
+            data = contract_state.encode("utf-8")
+            self.contract_checksum = hashlib.new("sha256", data).hexdigest()
+
+    def same_contract(self, old) -> bool:
+        if old.contract is False and self.contract is False:
+            # Not a change
+            return True
+        if old.contract is False and self.contract is True:
+            # A change, but not a breaking change
+            return False
+        if old.contract is True and self.contract is False:
+            # Breaking change: throw an error
+            raise (ModelContractError(reason="contract has been disabled", node=self))
+        if self.contract_checksum == old.contract_checksum:
path: core/dbt/contracts/graph/nodes.py
text: @user1:
Should this be a `!=` instead? I'd expect the `contract_checksum` values to be different between `self` and `old` if the model contract has changed.

diff_hunk: @@ -314,6 +314,7 @@ def update_parsed_node_config(
         # compatibility with earlier node-only config.
         if config_dict.get("contract", False):
             parsed_node.contract = True
+            parsed_node.build_contract_checksum()
path: core/dbt/parser/base.py
text: @user1:
This should be called once the `parsed_node` has `columns`, since the contract checksum is derived from the columns. Otherwise `parsed_node.columns` will always be `{}` and produce the same checksum. I think that's what's currently happening. Perhaps we could call `build_contract_checksum` [here](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/parser/schemas.py#L940) instead, after the config and columns are patched, and constraints are validated.

diff_hunk: @@ -261,3 +263,38 @@ def test_changed_exposure(self, project):
         results = run_dbt(["run", "--models", "+state:modified", "--state", "./state"])
         assert len(results) == 1
         assert results[0].node.name == "view_model"
+
+
+class TestChangedContract(BaseModifiedState):
+    def test_changed_contract(self, project):
+        self.run_and_save_state()
+
+        # update contract for table_model
+        write_file(contract_schema_yml, "models", "schema.yml")
+
+        # This will find the table_model node modified both through a config change
+        # and by a non-breaking change to contract: true
+        results = run_dbt(["run", "--models", "state:modified", "--state", "./state"])
+        assert len(results) == 1
+        assert results[0].node.name == "table_model"
+        manifest = get_manifest(project.project_root)
+        model_unique_id = "model.test.table_model"
+        model = manifest.nodes[model_unique_id]
+        expected_unrendered_config = {"contract": True, "materialized": "table"}
+        assert model.unrendered_config == expected_unrendered_config
+
+        # Run it again with "state:modified:contract", still finds modified due to contract: true
+        results = run_dbt(["run", "--models", "state:modified.contract", "--state", "./state"])
+        assert len(results) == 1
+        # save a new state
+        self.copy_state()
+
+        # This should raise because a column has added a constraint
path: tests/functional/defer_state/test_modified_state.py
text: @user1:
Adding a constraint shouldn't be a breaking change - just a regular change to the model contract (returns `False` from `same_contract`, but does not raise `ModelContractError`.

diff_hunk: @@ -497,6 +507,39 @@ def depends_on_nodes(self):
     def depends_on_macros(self):
         return self.depends_on.macros
 
+    def build_contract_checksum(self):
+        # We don't need to construct the checksum if the model does not
+        # have contract enabled, because it won't be used.
+        # This needs to be executed after contract config is set
+        if self.contract is True:
+            contract_state = ""
+            # We need to sort the columns so that order doesn't matter
+            # columns is a str: ColumnInfo dictionary
+            sorted_columns = sorted(self.columns.values(), key=lambda col: col.name)
+            for column in sorted_columns:
+                contract_state += f"|{column.name}"
+                contract_state += column.data_type
+                contract_state += str(column.constraints)
+            data = contract_state.encode("utf-8")
+            self.contract_checksum = hashlib.new("sha256", data).hexdigest()
+
+    def same_contract(self, old) -> bool:
+        if old.contract is False and self.contract is False:
+            # Not a change
+            return True
+        if old.contract is False and self.contract is True:
+            # A change, but not a breaking change
+            return False
+        if old.contract is True and self.contract is False:
+            # Breaking change: throw an error
+            raise (ModelContractError(reason="contract has been disabled", node=self))
+        if self.contract_checksum == old.contract_checksum:
+            # Breaking change: throw an error
+            raise (ModelContractError(reason="column definitions have changed", node=self))
path: core/dbt/contracts/graph/nodes.py
text: @user1:
The reasons for a breaking change can occur at the same time in a state modification check. For example, column definitions changing as well as a contract going from enabled to disabled. Given that these checks are typically used in CI - could we batch up the reasons and raise a single `ModelContractError` with an array of `reasons`? That way the user would have the full picture in their CI logs.

diff_hunk: @@ -209,6 +209,25 @@ def _fix_dupe_msg(self, path_1: str, path_2: str, name: str, type_name: str) ->
             )
 
 
+class ModelContractError(DbtRuntimeError):
+    CODE = 10016
+    MESSAGE = "Contract Error"
+
+    def __init__(self, reason, node=None):
+        self.reason = reason
+        super().__init__(self.message(), node)
+
+    @property
+    def type(self):
+        return "Contract"
+
+    def message(self):
+        return (
+            "There is a breaking change in the model contract because {self.reason}; "
path: core/dbt/exceptions.py
text: @user1:
missing the f-string for formatting `self.reason` here.