Crosshire
← Handbook
BuildStructure · bundles · pipelines · DoD

06Standards & CI/CD

This route decides how code is laid out, how it reaches a workspace, and what has to be true before a pull request is merged. The three testability rules are the load-bearing part — they are what make every later testing route possible. A warehouse that skips them can still be shipped. It just cannot be proven, and it cannot be handed over.

Stand 29.07.2026

Code structure

Almost all untestable Databricks code is untestable for one of three reasons, and all three are layout decisions rather than testing decisions. Fix the layout and the tests become ordinary.

#The three testability rules

What
One: the transform is a pure function. DataFrame in, DataFrame out — no spark.read.table inside it, no table name, no catalog, no clock. Two: IO lives at the edges, in a thin caller the tests never import. Three: nothing sits between the logic and the test — no decorator, no notebook cell, no %run. A test imports the function the same way production does.
python
src/dwh/pipelines/transforms/silver_sales_order.py — pure, importable, no pipeline in sight
from pyspark.sql import DataFrame, functions as F

# Business rules 1, 2, 5 and 6 as a plain dict — the pipeline and the
# test both import THIS, so they can never disagree about the rules.
# Rule 3 (valid countries DE/AT/CH/NL) is deliberately absent: an order
# line carries no country, so that rule is enforced in the customer
# transform. An expectation naming a column the table does not have
# fails the pipeline at analysis, not at runtime.
EXPECTATIONS = {
    "amount_positive":   "amount_eur > 0 OR status = 'CANCELLED'",
    "known_currency":    "currency IN ('EUR', 'CHF')",
    "fx_rate_resolved":  "fx_rate IS NOT NULL",
    "order_not_ancient": "order_ts >= TIMESTAMP '2020-01-01'",
    "status_domain":     "status IN ('OPEN', 'SHIPPED', 'CANCELLED')",
}

def to_silver_sales_order(lines: DataFrame, rates: DataFrame) -> DataFrame:
    """lines: bronze.sap_vbap shape. rates: silver.fx_rate (currency, rate_date, fx_rate)."""
    return (
        lines.withColumn("rate_date", F.to_date("order_ts"))
        .join(rates, on=["currency", "rate_date"], how="left")
        # EUR is the base currency and is absent from the ECB file, so it is 1
        # by definition. Any OTHER currency with no rate stays NULL on purpose:
        # fx_rate_resolved then drops the row and the event log counts it. The
        # literal is cast, because a Python float would promote the whole
        # DECIMAL chain to DOUBLE.
        .withColumn(
            "fx_rate",
            F.when(F.col("currency") == "EUR", F.lit(1).cast("decimal(18,6)"))
             .otherwise(F.col("fx_rate")),
        )
        .withColumn(
            "amount_eur",
            F.round(F.col("quantity") * F.col("unit_price") * F.col("fx_rate"), 2),
        )
        .drop("rate_date")
    )
python
src/dwh/pipelines/silver_sales_order.py — the entire pipeline file. Three lines of glue.
from pyspark import pipelines as dp
from dwh.pipelines.transforms.silver_sales_order import EXPECTATIONS, to_silver_sales_order

@dp.materialized_view(name="sales_order_line")
@dp.expect_all_or_drop(EXPECTATIONS)
def sales_order_line():
    return to_silver_sales_order(
        spark.read.table("bronze.sap_vbap"),
        spark.read.table("silver.fx_rate"),
    )
Why here
This prevents the objection that ends every testing conversation before it starts: we cannot unit-test our transformations, they only run in a pipeline. That is true, and it is true because of layout, not because of Databricks.
With the split above, a test of business rule 2 — amount_eur = ROUND(quantity * unit_price * fx_rate, 2) — is four lines and runs on a laptop in under a second. Without it the same test costs a pipeline start, a seeded table and ninety seconds, so nobody writes it, so the rule is only checked when someone notices a wrong number in a dashboard.
Doing it
  • Split each transform into transforms/.py (pure) and a caller. Do the split mechanically first and improve the logic second — two commits, never one.
  • Move EXPECTATIONS out of the decorator call into a module-level dict in the transform file.
  • Pass anything non-deterministic in as an argument: as_of timestamps, run ids, the catalog. A transform calling current_timestamp() cannot be asserted against a fixed expected DataFrame.
  • Keep the wrapper under ten lines. If it grows logic, that logic has escaped the testable half.
Embedding it
  • Pick their ugliest notebook and do the split live with the author driving, not you. Twenty minutes, one transform, one passing test.
  • Ask one question in every review: 'which line of this can I call from a test?' If the answer is none, the layout is the finding, not the logic.
  • Have them do the second split alone and review it. The second one is where the rule becomes theirs.
  • When someone argues a transform is too small to extract, let them merge it — then ask them to write the test. The argument settles itself.
Defaults
  • transforms/ holds pure functions; pipelines/ and jobs/ hold IO. Tests import only transforms/.
  • EXPECTATIONS is a dict in the transform module, imported by both the pipeline and the test.
  • Clock, catalog and run id are arguments, never module-level reads.
  • The wrapper is glue thin enough that nobody wants to test it.
Gotchas
  • spark.read.table() inside the transform. Every test then needs a real table, so the suite needs a warehouse, so it moves to nightly, so it stops running before merge.
  • A transform calling F.current_timestamp(). The expected DataFrame changes every run, the developer 'fixes' it by asserting only row counts, and the test now passes on wrong values.
  • Expectations written inline in the decorator. The test asserting a rule re-types the same SQL string, the two drift after the first edit, and the test keeps passing against a rule production no longer runs.
  • dbutils.widgets.get() inside a transform. It works in a notebook and raises outside one, so the function is importable only from Databricks — which is the problem you were trying to leave.
  • coalesce(fx_rate, 1.0) as a 'safe' default for a missing rate. A CHF order is then booked at 1:1, revenue is overstated by roughly a tenth, and nothing fails — no expectation fires, no job goes red. Default the base currency by name and let every other missing rate stay NULL so an expectation can count it.
  • An expectation naming a column the transform does not produce — country on an order line is the classic. Lakeflow rejects it when the pipeline is analysed, so it fails on the first deploy rather than in review, and the fix is usually to move the rule to the table that owns the column.

#Workspace files, not a wheel

What
Deploy src/ as workspace files inside the bundle and import from it. Do not build a wheel, upload it to a Volume, install it on a cluster and restart.
One prerequisite makes this safe rather than merely convenient. From Databricks Runtime 13.3 LTS, directories added to sys.path — or structured as Python packages — are automatically distributed to all executors. On 12.2 LTS and below they are not, and the symptom is precise: the same import works on the driver and raises ModuleNotFoundError inside a UDF.
python
src/dwh/jobs/silver_customer.py — bootstrap once, at the entry point only
import sys

# Both are job parameters set by the bundle, not relative paths and not
# an environment sniff.
source_root = dbutils.widgets.get("source_root")
catalog = dbutils.widgets.get("catalog")
if source_root not in sys.path:
    sys.path.insert(0, source_root)

from dwh.silver.customer import clean_customer   # noqa: E402

def run(spark, source: str, target: str) -> int:
    df = clean_customer(spark.read.table(source))
    df.write.mode("overwrite").saveAsTable(target)
    return df.count()

# The entry point is the only place a table name is assembled.
rows = run(spark, f"{catalog}.bronze.sap_kna1", f"{catalog}.silver.customer")
print(f"silver.customer: {rows} rows written")
yaml
resources/jobs.yml — where source_root comes from
resources:
  jobs:
    silver_customer:
      name: dwh-silver-customer
      tasks:
        - task_key: silver_customer
          notebook_task:
            notebook_path: ../src/dwh/jobs/silver_customer.py
            source: WORKSPACE
            base_parameters:
              source_root: ${workspace.file_path}/src
              catalog: ${var.catalog}
%run ./helpers is replaced by from dwh.common.keys import make_sk. The difference is not stylistic: %run executes a notebook into the current namespace, so nothing it defines can be imported by pytest, static analysis cannot see it, and two notebooks that both %run the same helper get two copies with independent state.
Why here
The wheel loop is where teams quietly stop iterating. Build, upload, reinstall, restart, rerun — five minutes for a one-character fix, so people edit in the notebook instead and copy the change back later. Sometimes later never arrives, and the deployed wheel and the repository diverge without anyone lying to anyone.
Workspace files also give you what a wheel never does: the code in the workspace is the code in the commit, because the bundle put it there. 'What is running in prod' becomes a one-line answer instead of an investigation.
Doing it
  • Put the whole package under src/dwh/ with __init__.py files and deploy it as workspace files via the bundle.
  • Bootstrap sys.path exactly once, in the entry-point file — never inside a transform or a library module.
  • Pass the source root as a job parameter resolved from ${workspace.file_path}. Do not compute it from a relative path or from os.getcwd().
  • Grep for %run and convert each one to an import in its own commit, so a revert is cheap. Check the runtime version on every cluster and pipeline first — 12.2 LTS is still in the wild.
Embedding it
  • Have the team time both loops themselves: one wheel rebuild, one bundle deploy, stopwatch on the table. The argument ends there.
  • Ask them to reproduce the 12.2 failure once by calling an imported helper from inside a UDF on an old cluster. Reading about it does not stick; watching the driver succeed and the executor fail does.
  • Make converting %run a rotating chore across the first three sprints rather than one heroic pull request nobody can review.
Defaults
  • src/dwh/ deployed as workspace files by the bundle. No wheel, no Volume, no cluster library.
  • One sys.path bootstrap per entry point, fed by a job parameter.
  • DBR 13.3 LTS or newer everywhere, so imported modules reach executors.
  • %run does not appear in the repository, and local pytest imports the identical package from src/ — no shim, no path hackery in conftest.py.
Gotchas
  • DBR 12.2 LTS or older: an import that works on the driver raises ModuleNotFoundError inside a UDF. It is intermittent by nature — the failure only appears once someone writes the first UDF, weeks after the layout was agreed.
  • Relative sys.path entries such as sys.path.append('../..'). DBR 14.0 changed the default working directory to the directory of the notebook being run, so a path written against 13.3 resolves somewhere else after an upgrade and fails in production only.
  • sys.path.append inside a library module. It runs on every import, the list grows, and import order starts deciding which copy of a module you get — a bug that reproduces on one cluster and not another.
  • A stale wheel still installed as a cluster library after the migration. It shadows the workspace files, so you debug the new code while the old code runs.

#Characterization test first, then refactor

What
You will propose the split above to a team whose code works. The answer you get is you want to rewrite our working code, and it is a fair objection. The response is not reassurance — it is a test that pins the current behaviour before a single line moves.
sql
Step 1 — snapshot what production produces TODAY, before touching anything
CREATE TABLE dwh_test.fixtures.char_fct_order_line_20260729
COMMENT 'Characterization snapshot of gold.fct_order_line, June 2026, taken before the transform refactor. Drop when the refactor is merged.'
AS
SELECT order_line_sk, order_id, line_number,
       customer_sk, product_sk, date_sk, region_sk,
       order_ts, quantity, unit_price, currency, fx_rate, amount_eur, status
       -- _tech_loaded_at is deliberately NOT projected. It changes on every
       -- run, so including it makes every row differ and the test useless.
FROM   dwh_prod.gold.fct_order_line
WHERE  order_ts >= DATE '2026-06-01'
  AND  order_ts <  DATE '2026-07-01';
sql
Step 2 — the test. Symmetric EXCEPT ALL: zero rows means the refactor changed nothing.
-- Same projection AND same window on both sides, or every row outside
-- June is reported as a difference and the test can never return zero.
WITH new_rows AS (
  SELECT order_line_sk, order_id, line_number,
         customer_sk, product_sk, date_sk, region_sk,
         order_ts, quantity, unit_price, currency, fx_rate, amount_eur, status
  FROM   dwh_test.gold.fct_order_line
  WHERE  order_ts >= DATE '2026-06-01'
    AND  order_ts <  DATE '2026-07-01'
)
SELECT 'new_not_in_old' AS side, d.* FROM (
  SELECT * FROM new_rows
  EXCEPT ALL
  SELECT * FROM dwh_test.fixtures.char_fct_order_line_20260729
) d
UNION ALL
SELECT 'old_not_in_new' AS side, d.* FROM (
  SELECT * FROM dwh_test.fixtures.char_fct_order_line_20260729
  EXCEPT ALL
  SELECT * FROM new_rows
) d;
The characterization test asserts nothing about correctness. It asserts sameness, which is exactly the promise you are making. If the refactor surfaces a genuine bug the test fails, you look, and you fix it as a separate change with its own test — never inside the refactor commit.
Why here
Without it a refactor is a negotiation about trust, and the consultant loses, correctly. With it the conversation changes shape: the team is no longer asked to believe you, they are shown a query that returns zero rows.
It protects you as well. Warehouse code accumulates undocumented behaviour — a rounding order, a coalesce nobody remembers adding, a filter that quietly drops one source. Those are load-bearing accidents, and the snapshot finds them before a report writer does.
Doing it
  • Take the snapshot from production output, not from a re-run of the old code — you are pinning what the business currently sees.
  • Choose a window wide enough to contain the awkward cases: a month with cancellations, at least one CHF order, at least one line hitting the UNKNOWN member.
  • Run EXCEPT ALL in both directions, with the identical column list and the identical window on both sides. One direction catches only half the differences, and EXCEPT without ALL hides duplicate-count changes entirely.
  • Keep the refactor commit behaviour-neutral, put an expiry in the snapshot's COMMENT, and actually drop it when the refactor merges.
Embedding it
  • Let the team choose the snapshot window. They know which month broke, and choosing it makes the test theirs rather than yours.
  • Have them break it deliberately — change a ROUND to a CAST and watch it fail — before they trust it to pass.
  • When the snapshot catches an undocumented behaviour, hand the finding to the team to explain to the business. That conversation is the one that changes how they feel about tests.
Defaults
  • Snapshot production output, never a re-run of the code under change.
  • Symmetric EXCEPT ALL, both directions, in one query with a side label.
  • One refactor commit, behaviour-neutral, reviewed on its own.
  • The snapshot table carries a COMMENT saying what it is for and when it dies.
Gotchas
  • Comparing the whole rebuilt table against a windowed snapshot. Every row outside the window comes back as new_not_in_old, the query can never return zero, and the team concludes the pattern does not work rather than that the filter is missing on one side.
  • Snapshotting a re-run of the old code instead of production output. You pin a behaviour the business has never seen, and a real production-versus-code drift stays invisible.
  • EXCEPT instead of EXCEPT ALL. EXCEPT deduplicates, so a refactor that doubles every row returns zero differences and passes.
  • Including _tech_loaded_at in the compared columns. Every row differs, the test is declared useless, and the team abandons the pattern on day one. Project the business columns explicitly.
  • Keeping the snapshot after the merge. Six months later it is a fixture nobody can explain, still failing CI whenever a legitimate change lands.

Bundles and CI

One bundle, three targets, one command per CI step. The interesting parts are the test target and the single bundle limitation that has a workaround worth knowing.

#The bundle and its test target

What
The bundle carries the whole deployable unit: jobs, pipelines and the src/ workspace files. The catalog is a variable per target, which is what lets the same code run against dwh_dev, dwh_test and dwh_prod without a single environment check at runtime.
yaml
databricks.yml — targets, and the CI job that exists only in test
bundle:
  name: dwh

include:
  - resources/*.yml

variables:
  catalog:
    description: Unity Catalog catalog this target writes to.
  deploy_sp:
    description: Application ID of the OAuth service principal that owns prod runs.

targets:
  dev:
    mode: development
    default: true
    variables:
      catalog: dwh_dev

  test:
    variables:
      catalog: dwh_test
    resources:
      jobs:
        ci_assert: &ci_assert
          name: dwh-ci-assert-${bundle.target}
          tasks:
            - task_key: seed
              notebook_task:
                notebook_path: ../src/dwh/ci/seed.py
                source: WORKSPACE
                base_parameters:
                  source_root: ${workspace.file_path}/src
                  catalog: ${var.catalog}
            - task_key: pipeline
              depends_on:
                - task_key: seed
              pipeline_task:
                pipeline_id: ${resources.pipelines.silver.id}
                full_refresh: true
            - task_key: assert
              depends_on:
                - task_key: pipeline
              notebook_task:
                notebook_path: ../src/dwh/ci/assert_expectations.py
                source: WORKSPACE
                base_parameters:
                  source_root: ${workspace.file_path}/src
                  catalog: ${var.catalog}

  sandbox:
    variables:
      catalog: dwh_dev
    resources:
      jobs:
        ci_assert: *ci_assert     # referenced, not redefined

  prod:
    mode: production
    variables:
      catalog: dwh_prod
    run_as:
      service_principal_name: ${var.deploy_sp}
    # ci_assert is simply ABSENT here. That is the workaround.
You cannot define ci_assert as a top-level resource and then exclude it from prodexcluding a top-level bundle resource for a single target is not supported. So the seed/run/assert job is defined inside the first target that needs it, anchored with &ci_assert and referenced with *ci_assert from any other target that wants it. Targets that should not have it say nothing, which is the only reliable way to subtract.
Why here
A test target is what turns 'the tests pass' into 'the tests pass against a deployed pipeline in a real workspace'. Unit tests on a laptop cannot catch an expectation that never fires because the pipeline edition is CORE, or a task that fails because a notebook path is wrong in the bundle. Those are deployment defects and only a deployment finds them.
The variable-per-target design removes the most damaging incident class in this kind of project: a job that wrote to the wrong catalog because the catalog was a literal in a notebook someone copied.
Doing it
  • Define the catalog as a bundle variable resolved per target. No literal catalog names anywhere in src/.
  • Anchor any resource that belongs to some targets but not others, and define it in the first target that uses it. Keep anchors in databricks.yml itself — they do not cross include: file boundaries.
  • Pin the Databricks CLI version in CI. Python bundle configuration requires 0.275.0 or newer, and an older CLI fails confusingly rather than with a version error.
  • Run databricks bundle validate -t prod locally before every release and read the resolved configuration it prints — variables are already substituted there, which is where a wrong catalog is visible before anything is deployed.
Embedding it
  • Have the team add the fourth target themselves. Twenty minutes, and it converts the bundle from your artefact into their configuration.
  • Ask them to predict what bundle validate will say before running it, then run it. Two rounds of that and they stop deploying blind.
  • When someone proposes an environment check inside the code, ask which target it is compensating for. The answer is always a missing variable.
Defaults
  • One bundle, one repository, one databricks.yml, resources split into resources/*.yml — anchors excepted.
  • Catalog per target as a variable; mode: production on prod so the CLI enforces production guardrails.
  • Subtract by omission and YAML anchors, never by trying to exclude a top-level resource.
  • Pin the CLI version in CI and in the developer setup instructions; prod runs as a service principal via run_as.
Gotchas
  • Trying to exclude a top-level resource for one target. It is not supported, so the CI job you wrote for test also lands in prod, where it seeds fixture data into the production catalog.
  • An anchor defined in resources/jobs.yml and referenced in databricks.yml. Anchors are per-document, so it never resolves and the message points at YAML syntax rather than at the include boundary.
  • An anchor referenced before it is defined. YAML resolves in document order, so reordering targets breaks a deploy that worked yesterday with no code change.
  • environment_key on a serverless notebook task is reported to be rejected. Unverified — but if a serverless task fails to start for no visible reason, this is the first thing to try removing.

#The CI pipeline and its identity

What
databricks bundle validate is the first step, before the linter and before pytest. It is the only step that catches a malformed bundle and it takes two seconds — running it after a ninety-second test suite wastes ninety seconds every time the YAML is wrong.
yaml
.github/workflows/pr.yml — cheapest and most decisive step first
name: dwh
on:
  pull_request:
    branches: [main]

jobs:
  verify:
    runs-on: ubuntu-latest
    env:
      DATABRICKS_HOST:          ${{ secrets.DATABRICKS_HOST }}
      DATABRICKS_CLIENT_ID:     ${{ secrets.DATABRICKS_CI_CLIENT_ID }}
      DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CI_CLIENT_SECRET }}
    steps:
      - uses: actions/checkout@v4

      - uses: databricks/setup-cli@main
        with:
          version: 0.275.0          # pinned; see the bundles-python fact

      - name: bundle validate
        run: databricks bundle validate -t test

      # Catches uppercase AND non-ASCII in one pass, and only in the table
      # name — a COMMENT holding a German term is untouched.
      - name: identifier rule
        run: |
          if grep -rEn 'CREATE (OR REPLACE )?TABLE (IF NOT EXISTS )?[^ (]*[^a-z0-9._ (]' src/; then
            echo "table names must match ^[a-z][a-z0-9_]*$ and be ASCII" >&2
            exit 1
          fi

      - name: unit tests
        run: pytest tests/unit --junitxml=reports/unit.xml

      - name: deploy to test
        run: databricks bundle deploy -t test

      - name: seed, run, assert
        run: databricks bundle run -t test ci_assert
CI authenticates as an OAuth service principal using machine-to-machine credentials: DATABRICKS_CLIENT_ID and DATABRICKS_CLIENT_SECRET, which the CLI exchanges for a short-lived token on every invocation. Personal access tokens do not appear in this repository, this pipeline, or anyone's .databrickscfg on a shared machine.
Why the identity choice is not a preference
Personal access tokenOAuth service principal
Belongs toa personthe pipeline
When that person leavesprod deploys stopnothing happens
In system.access.audittheir name, on every automated runthe service principal, distinguishable from humans
Lifetimelong-lived secret at restshort-lived token minted per call
Scopeeverything the person can doexactly the grants the SP was given
Why here
The audit row decides it. If CI runs as a person, system.access.audit cannot separate what a human did from what a pipeline did — so the standing access review has no signal, and 'who read the PII table' is unanswerable for the exact identity that touches everything.
The step ordering is a smaller point with a daily payoff: validate, then a fast static check, then unit tests, then anything that touches a workspace. Every step needing the network runs after every step that does not.
Doing it
  • Create one service principal per environment, grant it only what its target needs, and store its secret in the CI secret store — never in the repository, never as a bundle variable default.
  • Revoke every personal access token used for deployment in week one and confirm with the token API that none remain.
  • Make bundle validate the first step and let it fail the build. A warning nobody reads is not a check.
  • Encode the identifier and read-direction rules from the architecture route as grep steps here, and emit JUnit XML so failures surface in the pull request rather than in a log nobody opens.
Embedding it
  • Have the team add the next CI check themselves — the read-direction grep is the natural second one, and it is ten lines.
  • Ask them to run the access-review query and point at their own CI service principal in the output. Seeing it as a distinct identity is what makes the rule stick.
  • Rotate the service principal secret once during the engagement, with the team driving, so the runbook is written by someone who has done it.
Defaults
  • bundle validate first, static checks second, unit tests third, workspace steps last.
  • OAuth M2M service principals for every automated identity; zero personal access tokens.
  • One service principal per environment, granted per target, secret in the CI secret store.
  • Pinned CLI version, JUnit XML published to the pull request, and every agreed standard that can be a grep is a grep.
Gotchas
  • CI running as a personal access token belonging to whoever set it up. It works until that person leaves or the token expires, and then production deployment is blocked by an account nobody can access.
  • bundle validate after the test suite. Every YAML typo costs a full test run before you learn about it, which teaches people to push and wait rather than validate locally.
  • A service principal granted ALL PRIVILEGES on the metastore 'to unblock CI'. It never gets narrowed, and the identity with the widest grants in the warehouse is the one whose secret sits in a CI configuration.
  • Deploying to test from a branch without an isolated schema. Two pull requests deploy the same job name, the second overwrites the first, and both builds report failures caused by each other.

Definition of done

A definition of done is only useful if a reviewer can check every line of it in under a minute without asking the author anything.

#A definition of done that bites

What
The usual version — code reviewed, tests written, documentation updated — cannot be checked, so it is never enforced, so it is decoration. Each row below names the artefact and where the reviewer looks for it.
Definition of done for a warehouse pull request
Done meansReviewer checks
Grain is declaredthe table COMMENT contains a 'one row per …' sentence, in the DDL in this diff
The transform is importablea test imports it directly, with no pipeline and no table
Business rules are asserted, not describedeach rule touched by the diff maps to an EXPECTATIONS entry or a .sql assertion returning zero rows
Behaviour-neutral changes are proven neutrala symmetric EXCEPT ALL result is linked in the pull request
The bundle still validatesbundle validate is green for every target, not only test
No literals that vary by environmentgrep the diff for dwh_dev / dwh_prod; the catalog arrives as a variable
Identifiers pass the rule^[a-z][a-z0-9_]*$ — the CI identifier step is green and no new identifier in the diff needs backticks
Ownership is realthe object is owned by a group; no individual appears as owner in the DDL
The re-run question is answeredthe runbook says whether this job is safe to re-run blind, updated here if the answer changed
PII is classifiedany new column carrying personal data has its governed tag applied in the DDL, not clicked in the UI
Ten rows. Each is binary, each is visible in the diff or in the build, and none of them requires the reviewer to trust the author. That is the whole design goal.
Why here
An unenforceable definition of done fails in a specific and expensive way: the first three sprints look fine, then the first person leaves and it turns out nothing was written down. The grain lived in someone's head, the re-run question was answered in a chat thread, and the tags were clicked in the UI by a contractor whose access has since been revoked.
It also decides whether handover is possible. Every row above is something the team must be able to check without you in the room. A row that only works when the consultant reviews it is not a standard — it is a dependency.
Doing it
  • Put the ten rows in the pull-request template, so the reviewer reads them at the moment they matter.
  • Automate every row that can be automated — identifiers, environment literals, bundle validate — and leave the rest as explicit reviewer questions.
  • Add a row only when a real defect escaped that it would have caught, and name that defect in the commit message.
  • Delete a row that has never once failed, and timebox review: if checking the list takes more than five minutes, the pull request is too big and that is the finding.
Embedding it
  • Have the team draft the list and argue you down on two rows. A list they negotiated is one they apply when you are gone.
  • Rotate the reviewer role weekly so no single person becomes the definition of done.
  • Once a month, check a merged pull request against the list in front of everyone. Find one miss — there is always one — and add the automation rather than the reprimand.
  • Stop reviewing personally at the point they catch more than you do. Say the date out loud; that is the handover milestone, not the final presentation.
Defaults
  • Ten rows or fewer, each binary, each visible in a diff or a build.
  • Anything automatable is automated; the manual rows are the ones needing judgement.
  • The list lives in the pull-request template in the repository, versioned like code.
  • Rows are added in response to escaped defects and removed when they stop firing.
Gotchas
  • Rows that cannot be checked — 'code is clean', 'documentation updated'. They train reviewers to tick boxes without looking, which devalues the rows that do matter.
  • A twenty-row list. Review becomes a chore, people tick everything, and the list now provides false assurance rather than none.
  • Enforcing it only on the consultant's pull requests. The standard has to bind everyone from week one or it reads as an outsider's rule and dies at handover.
  • No written exception path. There will be an emergency fix; without one carrying an expiry date, the emergency becomes the precedent.

Straight to the documentation

The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.

Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register.