Architecture Decision Record
ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate
Status
Accepted (2026-07-28). Revised 2026-08-01: the diff now fails closed in both repos (the || true
is gone and MMCA.Store's build-and-test checkout sets fetch-depth: 0), so the fail-open trade-off
recorded on acceptance is now history rather than current behavior. Revised 2026-09-19: the
combined-archive migration projects the pathspec carve-out described have been deleted (both repos
are per-module migration projects only), the guard steps are additionally gated on
needs.changes.outputs.code, MMCA.Helpdesk's CI now has four jobs and one of them applies a
migration in a generated seed app, and MMCA.Common now commits one real EF Core migration as a test
fixture. The decision itself is unchanged. Revised 2026-10-07: anchors refreshed after the v1.233.0 release, and the
Decision's MMCA.Store regex anchor corrected to the regex line. Revised 2026-10-21: the filter case of
an in-place index rebuild is attributed to the MMCA.ADC override marker rather than to MMCA.Store's
CONTRIBUTING (which documents INCLUDE columns only), and anchors refreshed. Revised 2026-10-26: the
guard in both repos now also flags RenameColumn and RenameTable (five operations, not three),
which both CONTRIBUTING files do not yet document; the rollback is described as skipping any app
whose newest revision is live; and anchors refreshed. Revised 2026-10-28: both CONTRIBUTING files
now document all five guarded operations (the renames included), so the written rule no longer lags
the guard; and the CONTRIBUTING anchors refreshed.
Context
ADR-030 decides who applies a migration: every service host runs DatabaseInitStrategy = Migrate
and self-applies its pending EF Core migrations at startup as the sole migrator, with no deploy-step
sqlcmd backstop (MMCA.Store/.github/workflows/deploy.yml:1338-1348,
MMCA.ADC/.github/workflows/deploy.yml:1457-1470). It says nothing about what shape a migration
may take.
That gap is load-bearing because production rollback is revision-only. When the post-deploy smoke
gate fails, the deploy rolls back each container app whose newest revision is not live, copying its
newest healthy, active earlier revision with az containerapp revision copy --from-revision
(MMCA.Store/.github/workflows/deploy.yml:1531, MMCA.ADC/.github/workflows/deploy.yml:1631). An
app whose newest revision is already serving all traffic is skipped, so a smoke failure on a fleet
that activated fully rolls nothing back (MMCA.Store/.github/workflows/deploy.yml:1504-1508,
:1517-1520; MMCA.ADC/.github/workflows/deploy.yml:1602-1604, :1608-1615). For an app that is
rolled back, the copy reverts the image and nothing else:
the new revision already migrated the database on boot, and no down-migration runs. The previous
release therefore keeps serving traffic against the new schema, which is exactly the statement
both repos' CONTRIBUTING makes (MMCA.Store/CONTRIBUTING.md:58-62, MMCA.ADC/CONTRIBUTING.md:58-63).
A DropColumn shipped alongside the code that stopped reading it makes the rollback path a broken
release rather than a recovery.
The existing build-time gate does not cover this. dotnet ef migrations has-pending-model-changes
(MMCA.Store/.github/workflows/deploy.yml:415-429, MMCA.ADC/.github/workflows/deploy.yml:451-466)
asserts a migration exists for every model change; it has no opinion on whether that migration is
survivable one release back. The gate below is the shape rule that ADR-030 left open. It arrived in
MMCA.ADC with the 2026-07-19 security/resilience/ops review batch (commit adee5058, PR #38) and was
ported to MMCA.Store on 2026-07-25 (commit 1dfdc991, PR #52).
Decision
Schema changes follow expand/contract, and a CI step enforces the contract half.
- Expand now, contract later, as a written rule. Adding nullable columns, new tables and new
indexes is safe in any release; destructive operations belong to a LATER release, once no revision
that reads the old shape can still be rolled back to. Both CONTRIBUTING files name the contract step
as
DropColumn/DropTable/DropIndex/RenameColumn/RenameTable(MMCA.Store/CONTRIBUTING.md:64-68,MMCA.ADC/CONTRIBUTING.md:65-68) and describe the guard with the same five operations (MMCA.Store/CONTRIBUTING.md:70-74, op list:72;MMCA.ADC/CONTRIBUTING.md:70-75, op list:72), so the written rule matches what the guard below enforces. - A migration ADDED by a PR may not drop or rename without a marker. The
Expand/contract migration guard (schema rollback safety)step fails the build when a newly added migration'sUp()matches\.(DropColumn|DropTable|DropIndex|RenameColumn|RenameTable)\s*(<[^>]*>)?\s*\(and the sameUp()body does not carry the override marker (MMCA.Store/.github/workflows/deploy.yml:431-483, regex at:475;MMCA.ADC/.github/workflows/deploy.yml:234-283, regex at:275). Those five operations are the entire matched set. The renames are in it because EF emits them silently when a property or entity is renamed, and a rename breaks the old revision exactly like a drop (step commentMMCA.Store/.github/workflows/deploy.yml:436-437and::error::text:477;MMCA.ADC/.github/workflows/deploy.yml:239-240and:277). - The marker's documented format is one comment line:
// EXPAND-CONTRACT-OVERRIDE: <why this drop is safe one release back>(MMCA.Store/CONTRIBUTING.md:76-78,MMCA.ADC/CONTRIBUTING.md:77-79). What the step actually enforces is looser:grep -q 'EXPAND-CONTRACT-OVERRIDE'over theUp()body (MMCA.Store/.github/workflows/deploy.yml:476,MMCA.ADC/.github/workflows/deploy.yml:276), so the token must appear insideUp(), but the//prefix, the colon and the reason text are convention, not validation. - "Added by this PR" means git-added, base-relative, path-scoped. The step fetches the PR base and
runs
git diff --diff-filter=A --name-only "origin/<base_ref>...HEAD"limited toSource/Hosting/MMCA.Store.Migrations.SqlServer.*/Migrations/*.cs(respectivelyMMCA.ADC.Migrations.SqlServer.*) (MMCA.Store/.github/workflows/deploy.yml:459-460,MMCA.ADC/.github/workflows/deploy.yml:259-260).*.Designer.csfiles are skipped explicitly (MMCA.Store/.github/workflows/deploy.yml:471-473,MMCA.ADC/.github/workflows/deploy.yml:271-273), the model snapshot is a modification rather than an addition so it never enters the list, and the dot before the wildcard means only per-module migration projects match. Today that is every migration project in both repos:MMCA.Store.Migrations.SqlServer.{Catalog,Identity,Sales}andMMCA.ADC.Migrations.SqlServer.{Conference,Engagement,Identity,Notification}(MMCA.Store/Source/Hosting/,MMCA.ADC/Source/Hosting/), so nothing in either tree sits outside the pathspec. - Only the
Up()body is scanned. The body is extracted withawk '/protected override void Up\(/{flag=1} /protected override void Down\(/{flag=0} flag'(MMCA.Store/.github/workflows/deploy.yml:474,MMCA.ADC/.github/workflows/deploy.yml:274), because every additive migration'sDown()legitimately drops whatUp()added andDown()never runs at startup: down-migration is explicit tooling only. - It is a merge gate, not a deploy gate. The step lives in the
build-and-testjob, which both repos run only onpull_request(MMCA.Store/.github/workflows/deploy.yml:261,MMCA.ADC/.github/workflows/deploy.yml:225) and document as a required merge check (MMCA.Store/CONTRIBUTING.md:37-38,MMCA.ADC/CONTRIBUTING.md:37-38). Nothing re-checks the shape on the push tomainthat deploys. The step also carries the repo-wide change filterif: needs.changes.outputs.code == 'true'(MMCA.Store/.github/workflows/deploy.yml:432,MMCA.ADC/.github/workflows/deploy.yml:235), so a PR thechangesjob classifies as docs-only skips the guard entirely. That is not a hole:codegoes false only when every changed file is Markdown (MMCA.Store/.github/workflows/deploy.yml:192-193,MMCA.ADC/.github/workflows/deploy.yml:138-139), and a PR that adds a migration.csfile always sets it true. - The common legitimate override is an index rebuilt in place. Adding INCLUDE columns emits a
DropIndeximmediately followed by aCreateIndexunder the same name, which a one-release-back revision reads as a superset of what it expects (MMCA.Store/CONTRIBUTING.md:80-82; MMCA.ADC's CONTRIBUTING has no such paragraph). Adding a filter also drops and recreates the index under the same name within one migration, though not necessarily as adjacent operations. No CONTRIBUTING documents that case; ADR-095 does, for the soft-delete unique-index filter (Website/docs-src/adr/095-soft-delete-unique-indexes.md:135-153), and each marker argues it for its own index. The live markers say exactly that:MMCA.Store/Source/Hosting/MMCA.Store.Migrations.SqlServer.Sales/Migrations/20260725133726_AddOutboxInboxRetentionIndexes.cs:13-17(outbox pending index gains INCLUDE columns) andMMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Identity/Migrations/20260720031638_CommonV1120OutboxLeaseAndSoftDeleteIndexFilters.cs:14-17(unique index dropped at:18and recreated with an[IsDeleted] = 0filter at:37-43, twoAddColumnoperations between them). The second legitimate case is an index replaced by a wider composite with the same leading column, reasoned out inMMCA.Store/Source/Hosting/MMCA.Store.Migrations.SqlServer.Sales/Migrations/20260725044543_AddOrderStatusAndCreatedOnIndexes.cs:13-17.
Adoption is partial, and deliberately so at the deployed repos only. MMCA.ADC and MMCA.Store both
run the gate, in identical form. MMCA.Helpdesk does not have it: none of its four CI jobs carries
an expand/contract step (changes at MMCA.Helpdesk/.github/workflows/ci.yml:17, build-and-test
at :58, template-smoke at :120, postgresql-canary at :146). One of them does run
migrations: postgresql-canary installs dotnet-ef (:180-186) and then runs
build/templates/canary-postgresql.ps1 (:191-193), which scaffolds an InitialCreate and applies
it against a real PostgreSQL service container
(MMCA.Helpdesk/build/templates/canary-postgresql.ps1:197, :222). That proves a generated seed
app migrates on a second engine; it says nothing about the shape of migrations this repo commits.
Helpdesk's own tree
already carries an unmarked DropIndex in an Up() body
(MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.Migrations.SqlServer.Tickets/Migrations/20260725121253_AddOutboxInboxRetentionIndexes.cs:13-16),
which is consistent: Helpdesk has no deploy workflow and therefore no revision-rollback model to
protect. MMCA.Common does not have it either, and cannot: the framework ships no consumer-facing
migrations, and there is no Migrations directory anywhere in the repo. The one real EF Core
migration it commits is a test fixture, CreateMigrationProofTable
(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests.MigrationsFixture/CreateMigrationProofTable.cs:23-27),
a single additive CreateTable against the framework's SQLite context, kept in its own tiny library
outside the test assembly so that only tests naming that assembly as their migrations assembly ever
see it. Nothing a consumer deploys applies it. Consumer migrations still create the shared
OutboxMessages / InboxMessages tables the framework defines, so a framework-driven shape change
lands as an added migration in each consumer, which is where the gate sees it.
Rationale
- Rollback is one-way for schema, so the check belongs where the drop is still cheap. The only moment a destructive migration can be reconsidered for free is the PR that adds it; after the deploy the options are roll forward or restore (ADR-009).
- A grep is enough, and cheap enough to always run. The gate needs no database, no EF invocation and no new tool; it is a diff plus two greps inside a job that already runs on every PR, so it costs seconds and cannot itself become a flaky gate.
- An override that carries a reason beats a bypass label. The marker lives in the migration file next to the operation it excuses, so the next reader of that migration gets the argument for why the drop was safe, rather than a green check whose reasoning died with the PR thread.
- Up-only scanning keeps the false-positive rate at zero. Every additive migration's
Down()drops what it added; scanning both bodies would flag essentially every migration and the gate would be disabled within a week. - Invariant over discipline. This is the same posture as the architecture fitness functions (ADR-015) and the integration-event schema rule (ADR-010): a compatibility convention that a code review is expected to catch is a convention that eventually ships broken.
Trade-offs
- Five operations, not a model of compatibility.
AlterColumnnarrowing a type or flipping a column to NOT NULL,DropForeignKey,DropPrimaryKey,DropSchemaand a rawmigrationBuilder.Sql("DROP ...")all pass unflagged. The gate catches the common destructive shapes, not every one. - The marker is per-migration and unvalidated. One occurrence anywhere in
Up()exempts every destructive operation in thatUp(), and nothing checks that the text after the token is a real reason, so the gate ultimately enforces "state a reason", not "have a good one". - Added-only, and pull-request-only. A destructive statement appended to a migration file that is
already on
mainis a modification, not an addition, and is never scanned; neither is anything that reachesmainwithout a PR. - The diff fails closed, and the checkout it depends on is elsewhere in the file. Neither repo
wraps the diff in
|| trueany more: an unresolvable diff prints an::error::and exits 1 (MMCA.Store/.github/workflows/deploy.yml:454-463,MMCA.ADC/.github/workflows/deploy.yml:252-263), and only a diff that resolves to an empty added list prints "No new migration files in this diff: expand/contract guard passes." and exits 0 (MMCA.Store/.github/workflows/deploy.yml:464-467,MMCA.ADC/.github/workflows/deploy.yml:264-267). Bothbuild-and-testcheckouts now setfetch-depth: 0(MMCA.Store/.github/workflows/deploy.yml:265-271,MMCA.ADC/.github/workflows/deploy.yml:229-232), so the two repos run the same step against the same git object graph. The residual cost is that the gate's correctness lives in another step'swith:block, directly above the guard in MMCA.ADC but about 160 lines above it in MMCA.Store (the OpenAPI, coverage and EF steps sit between them): drop thefetch-depthand every PR reds on a step that has nothing to do with the change, which is the deliberate direction for that failure to point. As accepted (2026-07-28) this record described the opposite. The diff was$(git diff ... || true)and the MMCA.Store checkout was shallow, so the step passed vacuously on every run from 2026-07-25 to 2026-07-28, a three-day window in which a required check reported green without ever evaluating a migration (MMCA.Store/.github/workflows/deploy.yml:267-270). - It gates the migration, not the application. Nothing verifies that the previous release's code tolerates the new schema; an expand migration that adds a required column the old revision never writes is invisible to the gate.
- Partial adoption is a real gap for the reference app. MMCA.Helpdesk is the seed developers copy, and the rule it teaches by example today is the unmarked drop.
Revision (2026-10-01)
No decision or rationale changed. Both deploy.yml files grew, so every workflow citation is
re-anchored to the current lines: the startup-migrate note (MMCA.Store/.github/workflows/deploy.yml:1366-1373,
MMCA.ADC/.github/workflows/deploy.yml:1474-1484), the rollback call (Store :1551, ADC :1640), the
model-drift gate (Store :411-425, ADC :430-444), the guard step (Store :427-478, ADC :234-282)
with its diff, Designer skip, Up() extraction, regex and marker lines, the pull_request condition
(Store :257, ADC :225), the Markdown-only code filter (Store :189-190, ADC :138-139) and the
fetch-depth: 0 checkouts (Store :261-267, ADC :229-232). The regex, the three-operation set and
the fail-closed behavior are unchanged. The one wording correction: in MMCA.Store the checkout now sits
about 160 lines above the guard rather than ninety.
Revision (2026-10-06)
- No decision, rationale or behavior changed: the regex, the three-operation set, the
Up()-only scan and the fail-closed diff are as recorded. Bothdeploy.ymlfiles grew again, so the live-section anchors moved. MMCA.Store: guard step:430-481(diff:457-458, Designer skip:469-471,Up()extraction:472, regex:473, marker:474, fail-closed:454-461, empty list:462-465),pull_requestcondition:260, checkout:264-270with the vacuous-window comment at:266-269, step filter:431, Markdown-only filter:192-193, model-drift gate:414-428, startup-migrate note:1421-1429, rollback:1606. MMCA.ADC: model-drift gate:435-449, startup-migrate note:1550-1559, rollback:1715. - The 2026-10-01 revision's statement that every workflow citation was re-anchored no longer holds for the lines it lists; those anchors stay as recorded on that date, and the current lines are above.
- Every anchor in the live sections was re-verified against current source on 2026-10-06; the MMCA.ADC
guard-step anchors (
:225,:229-232,:234-282,:258-259,:270-275,:253-266,:138-139) still hold.
Revision (2026-10-07)
Re-verified against current source. No decision, rationale or behavior changed: the regex, the
three-operation set, the Up()-only scan, the fail-closed diff, the Markdown-only code filter and
the revision-only rollback are as recorded. MMCA.Store's deploy.yml grew by one line above the
build-and-test job and shrank above the deploy phase, and MMCA.ADC's moved below its guard step, so
the live-section anchors moved again.
- The Decision bullet's MMCA.Store regex anchor pointed at the Designer-skip
caseblock rather than the regex; it now points at thegrep -qEline (MMCA.Store/.github/workflows/deploy.yml:474). The 2026-10-06 revision's statement that every live-section anchor was re-verified on that date no longer holds for the lines listed below; those anchors stay as recorded there. - Anchors re-verified against current source: MMCA.Store guard step
MMCA.Store/.github/workflows/deploy.yml:431-482(step filter:432, fail-closed:454-462, diff:458-459, empty list:463-466, Designer skip:470-472,Up()extraction:473, regex:474, marker:475),pull_requestcondition:261, checkout:265-271with the vacuous-window comment at:267-270, model-drift gate:415-429, startup-migrate note:1308-1316, rollback:1493; the Markdown-only filter:192-193still holds. MMCA.ADC: model-drift gateMMCA.ADC/.github/workflows/deploy.yml:437-452, startup-migrate note:1442-1451, rollback:1607; the guard-step anchors (:225,:229-232,:234-282,:253-266,:258-259,:270-275) and the Markdown-only filter:138-139still hold.
Revision (2026-10-21)
Re-verified against current source. No decision, rationale or behavior changed: the regex, the
three-operation set, the Up()-only scan, the fail-closed diff, the Markdown-only code filter, the
merge-check placement and the revision-only rollback are as recorded, and the override markers and
migration project sets still hold. The deploy-phase anchors in both deploy.yml files, the MMCA.ADC
model-drift gate and both CONTRIBUTING files moved.
- The Decision bullet on in-place index rebuilds attributed both the INCLUDE-column case and the
filter case to
MMCA.Store/CONTRIBUTING.md:76-78(that paragraph now sits at:78-80); it covers INCLUDE columns only, and MMCA.ADC's CONTRIBUTING has no equivalent. The filter case is now attributed to ADR-095 and to the markers that argue it (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Identity/Migrations/20260720031638_CommonV1120OutboxLeaseAndSoftDeleteIndexFilters.cs:14-17), and the bullet no longer says the drop and recreate are adjacent for that case (:18,:37). - Anchors re-verified against current source: the startup-migrate note
MMCA.Store/.github/workflows/deploy.yml:1337-1347andMMCA.ADC/.github/workflows/deploy.yml:1456-1469(no deploy-phase step invokessqlcmd; its only uses are in the integration-test job, the install at Store:622, ADC:677and the SQL Server wait at Store:644, ADC:697), rollback Store:1530, ADC:1625, MMCA.ADC model-drift gate:450-465(MMCA.Store:415-429still holds), the rollback statementMMCA.Store/CONTRIBUTING.md:58-61andMMCA.ADC/CONTRIBUTING.md:58-61, the expand/contract bullets:63-66in both, the marker block:74-76in both, the in-place rebuild paragraphMMCA.Store/CONTRIBUTING.md:78-80; the merge check:37-38in both, the MMCA.Store guard-step anchors (:431-482,:432,:458-459,:470-475,:261,:265-271,:192-193) and the MMCA.ADC guard-step anchors (:225,:229-232,:234-282,:258-259,:270-275,:138-139) still hold.
Revision (2026-10-26)
Re-verified against current source. The decision, the Up()-only scan, the marker check, the
fail-closed diff, the Markdown-only code filter, the merge-check placement and the revision-only
rollback are unchanged. What moved is the matched set (both guards now match five operations rather
than three), the rollback's reporting in MMCA.ADC, and the guard-step and rollback anchors in both
repos.
- The guard regex in both repos is now
\.(DropColumn|DropTable|DropIndex|RenameColumn|RenameTable)\s*(<[^>]*>)?\s*\((MMCA.Store/.github/workflows/deploy.yml:475,MMCA.ADC/.github/workflows/deploy.yml:275), added on 2026-10-24 (MMCA.Store commit4ee4e912, PR #224; MMCA.ADC commit9004ce8dd, PR #290). The step comments (Store:436-437, ADC:239-240) and the::error::text (Store:477, ADC:277) give the reason: EF emits a rename silently, and a rename breaks the one-release-back revision exactly like a drop. The Decision bullet now names five operations, and the Trade-offs bullet no longer listsRenameColumnamong the shapes that pass unflagged. The earlier revisions' statements that the three-operation set is unchanged stay as recorded on their dates. - Both CONTRIBUTING files still name only
DropColumn/DropTable/DropIndex, both as the contract step and in the guard description (MMCA.Store/CONTRIBUTING.md:66,:70;MMCA.ADC/CONTRIBUTING.md:66,:70), so the documented rule lags the five operations the guard enforces. The Decision's first bullet now records that gap, and its second bullet's heading names renames alongside drops. - The Context paragraph said a smoke failure rolls every container app back. Both rollbacks skip an
app whose newest revision is already live, so a smoke failure on a fleet that activated fully
rolls nothing back (
MMCA.Store/.github/workflows/deploy.yml:1504-1508,:1517-1520;MMCA.ADC/.github/workflows/deploy.yml:1602-1604,:1608-1615); the paragraph now says so. For an app that is rolled back the copy is still revision-only. In MMCA.ADC the rollback also gained per-app counters (:1606), a "no candidate revision" message (:1639-1640) and an always-printed summary line (:1651), on 2026-10-23 (commitf61e9ecda, PR #289), which moved itsrevision copycall from:1625to:1631. - Anchors re-verified against current source: MMCA.Store guard step
MMCA.Store/.github/workflows/deploy.yml:431-483(step filter:432, fail-closed:454-463, diff:459-460, empty list:464-467, Designer skip:471-473,Up()extraction:474, regex:475, marker:476), startup-migrate note:1338-1348, rollback:1531; MMCA.ADC guard stepMMCA.ADC/.github/workflows/deploy.yml:234-283(step filter:235, fail-closed:252-263, diff:259-260, empty list:264-267, Designer skip:271-273,Up()extraction:274, regex:275, marker:276), model-drift gate:451-466, startup-migrate note:1457-1470, rollback:1631. MMCA.Store's model-drift gate:415-429,pull_requestcondition:261, checkout:265-271(vacuous-window comment:267-270) and Markdown-only filter:192-193, MMCA.ADC's:225,:229-232and:138-139, and the CONTRIBUTING anchors (:37-38,:58-61,:63-66,:74-76in both,MMCA.Store/CONTRIBUTING.md:78-80) still hold. The MMCA.ADC fail-closed anchor changed scope rather than shifting: it now runs fromset -euo pipefailthrough theexit 1block (:252-263, previously recorded as:253-266).
Revision (2026-10-28)
Re-verified against current source. The decision, the five-operation regex
(MMCA.Store/.github/workflows/deploy.yml:475, MMCA.ADC/.github/workflows/deploy.yml:275), the
Up()-only scan, the marker check, the fail-closed diff, the merge-check placement and the
revision-only rollback are unchanged. What moved is the documentation in both CONTRIBUTING files.
- Both CONTRIBUTING files now name
RenameColumnandRenameTablealongside the three drops, both as the contract step (MMCA.Store/CONTRIBUTING.md:67,MMCA.ADC/CONTRIBUTING.md:68) and in the guard description (MMCA.Store/CONTRIBUTING.md:72,MMCA.ADC/CONTRIBUTING.md:72), added on 2026-10-26 (MMCA.Store commit013358fa, PR #230; MMCA.ADC commit73922be24, PR #298). The Decision's first bullet no longer records a gap between the written rule and the guard. The 2026-10-26 revision's item 2 and Status sentence stay as recorded on that date. - Anchors re-verified against current source: the rollback statement
MMCA.Store/CONTRIBUTING.md:58-62andMMCA.ADC/CONTRIBUTING.md:58-63("NEW schema" sits at:62in both; the sentence ends at Store:62and ADC:63), the expand/contract bullets Store:64-68, ADC:65-68, the guard paragraph Store:70-74, ADC:70-75, the marker block Store:76-78, ADC:77-79, and the in-place rebuild paragraphMMCA.Store/CONTRIBUTING.md:80-82(MMCA.ADC's CONTRIBUTING still has no such paragraph; its schema section ends at:79). The merge check:37-38in both still holds.
Related
ADR-030 (decides that each service self-applies its migrations at startup, which is precisely why a rolled-back revision meets the new schema; this ADR constrains what those migrations may contain), ADR-006 (database-per-service bounds a bad migration to one service's database), ADR-009 (a drilled restore is the backstop when a schema change cannot be rolled forward), ADR-010 (the message-contract sibling: additive changes stay compatible, breaking ones need a parallel path), ADR-015 (the invariant-over-discipline posture this gate applies to schema).