| id | 0060-multi-tenancy-migration-history-scope | ||||
|---|---|---|---|---|---|
| title | Multi-Tenancy: Per-Schema Placement of the Box Migration-History Table | ||||
| status | Accepted | ||||
| author |
|
||||
| created | 2026-05-27 | ||||
| summary | Introduces an opt-in `MigrationHistoryScope.PerSchema` enum on `BoxProvisioningOptions` and a single `ResolveHistorySchema()` method on `SqlBoxMigrationRunner` so MSSQL and PostgreSQL tenants can co-locate the `__BrighterMigrationHistory` table in their own schema rather than the shared default schema, with a backward-compatible `Global = 0` default. Includes a misconfiguration guard, an idempotent one-time seed that copies prior history rows from the legacy default-schema table on first per-schema creation, and schema-aware coordination of write and read sides through the detection helper. | ||||
| tags |
|
Date: 2026-05-27
Accepted
The box-provisioning migration runner records applied migrations in a history table — __BrighterMigrationHistory on the relational backends. Today that table is always created in the backend's default schema, regardless of the configured IAmARelationalDatabaseConfiguration.SchemaName:
- MSSQL →
[dbo].[__BrighterMigrationHistory](HISTORY_TABLE_SCHEMA = "dbo",MsSqlBoxMigrationRunner.cs:50) - PostgreSQL →
"public"."__BrighterMigrationHistory"(HISTORY_TABLE_SCHEMA = "public",PostgreSqlBoxMigrationRunner.cs:53) - MySQL → unqualified, lives in the connection-bound
DATABASE()(MySqlBoxMigrationRunner.cs:137)
Operators running a schema-per-tenant deployment want the history table inside the tenant's isolation, backup-restore, and retention boundary, not in a schema shared with other tenants. Spec 0029 (requirements approved 2026-05-27) fixes the behavioural contract; this ADR fixes the architecture that delivers it.
Parent Requirement: specs/0029-multi-tenancy-migrations/requirements.md
Scope: This ADR is the single architectural decision for spec 0029 — how to make migration-history placement an opt-in, schema-aware capability across the role/template-method box-provisioning architecture established by ADR 0057, ADR 0058, and ADR 0059. It covers six connected sub-decisions (the option, schema resolution, the misconfiguration guard, end-to-end schema-awareness, the existing-deployment seed, and observability). They are presented together because they share one responsibility — deciding and acting on where history lives — and splitting them would scatter that responsibility.
- Backward compatibility is non-negotiable (FR4/NF1): operators who do not opt in — including those who already set a non-null
SchemaName— must see byte-for-byte the same placement and no migration re-run. - Three call sites of one rule: the write side (runner: CREATE TABLE + INSERT history row), the authoritative read side (runner, under the advisory lock, via the detection helper), and a pre-lock non-authoritative read (
SqlBoxProvisioner.DetectTableStateAsync,SqlBoxProvisioner.cs:141-169, which callsDoesHistoryExistAsync/DetectCurrentVersionAsync/GetMaxVersionAsyncbefore the runner runs). The detection helper is a stateless DI singleton (MsSqlBoxDetectionHelper.cs:38) whose history queries currently hardcode the default schema (MsSqlBoxDetectionHelper.cs:93). If these sites resolve the schema independently they can diverge — duplicated knowledge (a design-principle violation) and a latent correctness bug. The provisioner's pre-lock read is explicitly a discarded hint (SqlBoxProvisioner.cs:155-157: "the runner re-detects under the lock") — the runner's under-lock detection is the single authoritative read — but the provisioner is still a compile-time caller of the detection methods and is in scope for the structural signature change. - Existing deployments (FR5): a deployment with a populated
dbo/publichistory table that flips toPerSchemamust not re-run already-applied migrations. Detection reads from the resolved (per-schema) table, where the prior rows do not yet live — so a flip needs the prior facts to reach the new location. - Backend asymmetry: MSSQL/PG have a true schema distinct from the database; MySQL's "schema" is a database (
MySqlOutboxMigrationCatalog.cs:232treats a configured schema as a separate database); SQLite/Spanner have no schema concept. Per-schema placement is therefore defined only for MSSQL and PostgreSQL (requirements Out of Scope). - Constraints: must reuse the
SqlBoxMigrationRunner<TConnection,TTransaction>template-method base (SqlBoxMigrationRunner.cs:54), the existing role interfaces,Paramore.Brighter.ConfigurationExceptionfor config guards, and the existingILogger/identifier-safety (Identifiers.AssertSafe,PgIdentifier) machinery. No new defaults; no behaviour change off the opt-in.
Introduce an explicit, opt-in MigrationHistoryScope and make history-table placement a first-class responsibility of the runner base, resolved once and handed to the detection helper, so write and read sides cannot diverge. Six connected decisions:
Add an enum and a property on the existing options type (BoxProvisioningOptions.cs):
namespace Paramore.Brighter.BoxProvisioning;
/// <summary>Controls where the box migration-history table is physically placed.</summary>
public enum MigrationHistoryScope
{
/// <summary>History lives in the backend default schema (dbo / public / connection DATABASE()).
/// The default — identical to behaviour prior to this feature.</summary>
Global = 0,
/// <summary>On MSSQL and PostgreSQL, history is created in the configured SchemaName,
/// co-located with the tenant's box tables. No-op on backends without a schema concept.</summary>
PerSchema = 1
}
// on BoxProvisioningOptions, alongside MigrationLockTimeout:
public MigrationHistoryScope MigrationHistoryScope { get; set; } = MigrationHistoryScope.Global;Global = 0 is the default so existing construction sites compile and behave unchanged (C2). The scope is threaded into each runner's constructor on the same call path as MigrationLockTimeout (BoxProvisioningOptions → UseBoxProvisioning → runner ctor).
Naming (requirements Additional Context):
ScopeoverSchemabecause it selects the breadth of history sharing, not a schema name;PerSchemaoverTenantbecause Brighter routes bySchemaNameand models no tenant concept. An enum (not a richer type) is appropriate for a closed two-value mode and reads at the call site.
The "decide which schema holds history" responsibility lives once, in SqlBoxMigrationRunner, and the resolved value is passed to the detection helper. Two new protected members on the base let each backend declare its capability and default without duplicating the rule:
// SqlBoxMigrationRunner<TConnection, TTransaction>
protected abstract string? DefaultHistorySchema { get; } // "dbo" | "public" | null (MySQL/SQLite)
protected virtual bool SupportsPerSchemaHistory => false; // overridden => true on MSSQL & PG
protected string? ResolveHistorySchema() =>
_scope == MigrationHistoryScope.PerSchema && SupportsPerSchemaHistory
? Configuration.SchemaName // guaranteed non-null by D3
: DefaultHistorySchema; // Global, or any non-placement backend- MSSQL overrides
DefaultHistorySchema => "dbo",SupportsPerSchemaHistory => true. - PostgreSQL overrides
DefaultHistorySchema => "public",SupportsPerSchemaHistory => true. - MySQL / SQLite:
SupportsPerSchemaHistorystaysfalse→ResolveHistorySchema()always returns the default →PerSchemais a no-op there (AC1b), with no exception (the D3 guard is gated onSupportsPerSchemaHistory). Spanner does not derive from this base (ADR 0057 §6) and is untouched.
The history-row SchemaName column value is unchanged — it remains the box table's schema (schemaName), as today. Only the physical table the rows live in moves. Under PerSchema the two coincide; under Global they may differ (box in billing, history in dbo) — which is exactly why a single hardcoded constant cannot serve both and a resolver is required.
At the top of MigrateAsync (base), on placement backends only:
if (_scope == MigrationHistoryScope.PerSchema && SupportsPerSchemaHistory && Configuration.SchemaName is null)
throw new ConfigurationException(
"MigrationHistoryScope.PerSchema requires a non-null SchemaName; there is no schema to place history in.");Gating on SupportsPerSchemaHistory is what makes FR1a (reject) and AC1b (MySQL no-op, no throw) consistent: the guard fires only where PerSchema actually performs placement. This reuses the established config-guard pattern (SqlBoxMigrationRunner.cs:165, :410).
Replace every hardcoded use of HISTORY_TABLE_SCHEMA with the resolved value, on both sides:
- Write side (runner):
EnsureHistoryTableAsyncCREATE DDL (MsSqlBoxMigrationRunner.cs:140,PostgreSqlBoxMigrationRunner.cs:130) and the history-row INSERT (:281/:295) qualify the table withResolveHistorySchema(). - Read side (detection helper): the history-reading methods —
DoesHistoryExistAsyncandGetMaxVersionAsync— gain a nullablehistorySchemaparameter (a structural interface change toIAmABoxMigrationDetectionHelper<TConnection,TTransaction>), wherenullmeans "the backend default schema" — i.e. today's behaviour. [Errata 2026-05-27 — see Errata section:DetectCurrentVersionAsyncis NOT in this set (it reads box-table columns, not history), soIAmAVersionDetectingMigrationHelper<,>does not change; and the MSSQLDoesHistoryExistAsyncexistence-delegation argument must also carryhistorySchema.] The helper builds the qualified name with the existing safe-quoting:- MSSQL:
Identifiers.AssertSafe+ bracket-quoting; CREATE/INSERT and the read existence/version queries all qualify with the resolved schema. - PostgreSQL: the resolved schema is folded identically on both sides —
PgIdentifier.Quotefor every"public"-qualified table reference, on the write side (CREATE/INSERT, today embedding the literal"public") and the read side (PostgreSqlBoxDetectionHelper'sDoesHistoryExistAsyncCOUNT qualifier:132andGetMaxVersionAsync's qualifier), plusPgIdentifier.Normalizefor theinformation_schemaexistence-check parameter (today the literalTABLE_SCHEMA = 'public'at:122). BecauseQuoteandNormalizeboth lower-case (PgIdentifier.cs:57,77), a mixed-caseSchemaName("Billing") folds tobillingon write and read alike, so detection finds the table it created. This adds no new injection surface (NF3) — the schema is the already-validatedSchemaName.
- MSSQL:
- Pre-lock read (
SqlBoxProvisioner): the provisioner calls the same three read methods before the runner runs (SqlBoxProvisioner.cs:141-169) and holds the runner only via the narrowIAmABoxMigrationRunnerinterface, so it cannot callResolveHistorySchema(). It passeshistorySchema: null— its pre-lock result is an explicitly discarded hint (SqlBoxProvisioner.cs:155-157); the runner's under-lock re-detection is authoritative, so FR3 ("detection and provisioning never disagree about which schema holds history") holds at the authoritative site. The provisioner is a mandatory structural-change call site (see Implementation Approach).
Because under Global ResolveHistorySchema() returns the same constant the code uses today, and historySchema: null/default reproduces today's queries, every existing code path is byte-for-byte unchanged (FR4/NF1).
When, under PerSchema, the per-schema history table does not yet exist at the start of a provisioning run and a legacy default-schema history table does exist, EnsureHistoryTableAsync — after creating the per-schema table, inside the same advisory-lock + transaction it already holds — copies the prior facts for this box into the new table:
-- All FIVE columns of __BrighterMigrationHistory, matching the CREATE DDL
-- (MsSqlBoxMigrationRunner.cs:141-148 / PostgreSqlBoxMigrationRunner.cs:130-137).
-- Description is NOT NULL with no default, so it MUST be copied — omitting it fails the insert.
INSERT INTO [<schema>].[__BrighterMigrationHistory]
(MigrationVersion, SchemaName, BoxTableName, Description, AppliedAt)
SELECT src.MigrationVersion, src.SchemaName, src.BoxTableName, src.Description, src.AppliedAt
FROM [<default>].[__BrighterMigrationHistory] src
WHERE src.SchemaName = @schemaName -- only this tenant's rows from the shared table
AND src.BoxTableName = @boxTableName
AND NOT EXISTS ( -- idempotent: composite-PK match already present
SELECT 1 FROM [<schema>].[__BrighterMigrationHistory] tgt
WHERE tgt.SchemaName = src.SchemaName
AND tgt.BoxTableName = src.BoxTableName
AND tgt.MigrationVersion = src.MigrationVersion);- One-time:
EnsureHistoryTableAsynctoday usesCREATE TABLE IF NOT EXISTSand returns no created-vs-pre-existed signal, so this feature adds a pre-create probe of the per-schema history table insideEnsureHistoryTableAsync(under the lock): if it was absent and a legacy default-schema table exists, run the seed after creating it. After the first per-schema run the legacy default-schema table is never read again — the per-schema table is fully self-contained and the tenant is isolated going forward (the feature's intent). Even without a perfect table-level gate, theNOT EXISTSrow guard below makes the seed idempotent. - Idempotent (NF2): the
NOT EXISTSguard means a retried/partial run cannot duplicate rows; the composite PK(SchemaName, BoxTableName, MigrationVersion)is the backstop. - Filtered: only rows whose
SchemaNamecolumn matches this tenant are copied, because the shared default table holds every tenant's history. - Fresh deployments that start on
PerSchemawith no legacy table simply skip the seed and take the normal fresh-install path.
This makes AC5 satisfiable by an automated integration test (set up dbo history → flip config → provision → assert no re-run), which a manual-script-only approach could not.
On each provisioning run the runner logs, at Information level (provisioning is a startup-time, low-volume operation; operators need to confirm placement), the active scope and resolved history schema:
Logger.LogInformation(
"Box migration history for {BoxTable} resolved to schema {HistorySchema} (scope {Scope})",
tableName, ResolveHistorySchema() ?? "<backend default>", _scope);When the D5 seed runs, a distinct Information log records the row count copied from the legacy table, and an OpenTelemetry Activity event is added to the migration span (consistent with the existing race-swallow event at MsSqlBoxMigrationRunner.cs:174-175).
BoxProvisioningOptions.MigrationHistoryScope (Global default)
│ (threaded via UseBoxProvisioning → runner ctor, same path as MigrationLockTimeout)
▼
SqlBoxMigrationRunner<TConn,TTx> ── owns placement decision + history-table lifecycle
│ D3 guard (MigrateAsync entry): PerSchema + placement-backend + null SchemaName → ConfigurationException
│ D2 ResolveHistorySchema() ◀── DefaultHistorySchema (abstract) + SupportsPerSchemaHistory (virtual)
│ │ single source of truth
│ ├─► D4 write side: EnsureHistoryTableAsync CREATE + history-row INSERT (qualified by resolved schema)
│ ├─► D5 seed (first per-schema creation only): copy legacy rows WHERE SchemaName=@tenant, NOT EXISTS
│ ├─► D6 log: Information(scope, resolved schema)
│ └─► passes resolved historySchema ▼
│ IAmAVersionDetectingMigrationHelper (stateless singleton)
│ D4 read side: existence / MAX(version) / detect — FROM <historySchema>.__BMH
▼
MsSql / PostgreSql runners → SupportsPerSchemaHistory = true, DefaultHistorySchema = "dbo" / "public"
MySql / Sqlite runners → SupportsPerSchemaHistory = false → PerSchema is a no-op (AC1b)
Spanner → does not derive from base (ADR 0057 §6); untouched
| Component | Stereotype | Responsibility | Change |
|---|---|---|---|
MigrationHistoryScope (enum) |
information holder | knows the operator's placement intent | new |
BoxProvisioningOptions |
information holder | holds the scope alongside MigrationLockTimeout |
+1 property |
SqlBoxMigrationRunner<T,Tx> |
coordinator / controller | decides the history schema (D2), guards misconfig (D3), acts on placement + seed (D4/D5), reports (D6) | base gains DefaultHistorySchema, SupportsPerSchemaHistory, ResolveHistorySchema(), seed logic |
MsSql/PostgreSql runners |
service provider | declare placement capability + default schema; emit qualified DDL/INSERT | override two members |
IAmAVersionDetectingMigrationHelper impls |
information holder / service provider | read history from the schema they are told to | history methods gain historySchema param |
The placement decision and the history-table lifecycle are cohesive and stay together on the runner (which already owns both). The detection helper stays a pure read-side service that is told where to look — it gains no decision-making. No new role/type is introduced beyond the enum: the resolver is a base method, not a class, because a single owning collaborator consumes it (principle: do not add types without necessity).
enum MigrationHistoryScope— closed two-value mode; expressive at call sites;Global = 0makes the safe default the zero-value.Paramore.Brighter.ConfigurationException— existing config-guard exception (ConfigurationException.cs:32).Identifiers.AssertSafe/PgIdentifier— existing identifier-safety; reused, no new injection surface.ILoggerInformation +Activityevent — matches the runners' existing logging/telemetry conventions.
Structural changes precede behavioural ones, in separate commits (design principle / /tidy-first):
- Structural: add the nullable
historySchemaparameter to the detection-helper role interfaces and all five impls, plus every call site — the runner's under-lock reads andSqlBoxProvisioner.DetectTableStateAsync(SqlBoxProvisioner.cs:151,158,166), which passesnull— withnull/default reproducing today's queries so behaviour is identical; add theenumand option defaulted toGlobal; addDefaultHistorySchema/SupportsPerSchemaHistory/ResolveHistorySchema()returning today's constant. Tests green, no behaviour change. - Behavioural (TDD,
/test-firstper slice, integration tests on real containers per project convention): D3 guard; D4 wiring ofResolveHistorySchema()into write+read sides; D5 seed; D6 logging.
- Operators get true per-tenant history isolation on MSSQL/PG, opt-in and upgrade-safe (FR1–FR4).
- Write and read sides resolve placement from one method — detection and provisioning cannot disagree (FR3); knowledge is not duplicated.
- Existing deployments can flip without re-running migrations, exercised by an automated test (FR5/AC5).
- Off the opt-in, every path returns today's constant → provably unchanged behaviour (FR4/NF1).
- Fits the existing template-method base; the per-backend delta is two small overrides.
- The detection-helper role interfaces gain a parameter — a breaking change to those interfaces (internal to box-provisioning, but public types). All five backends, the
SqlBoxProvisionerpre-lock call site, and any external implementor must add it (mitigated: the parameter is nullable andnull= today's behaviour). - The runner base grows three members plus seed logic — more surface on an already-central class.
PerSchemaon MySQL/SQLite is silently a no-op. It is documented (XML docs + AC1b) but an operator could still expect placement; mitigated by the Information log (D6) showing the resolved default schema.
- Cross-schema read permission at flip time (D5): the one-time seed reads the legacy default-schema table; under tenant-isolated credentials that may be denied. Mitigation: provisioning typically runs with elevated (DDL-capable) credentials; document that the
Global → PerSchemaflip must run with read access to the legacy history table, and surface a clear error if the read fails. The reverse flip is out of scope (requirements). - Legacy rows left behind: after the seed, the tenant's rows remain in the shared default table (harmless duplicates, ignored under
PerSchema). Mitigation: documented; cleanup is explicitly out of scope (requirements). - Partial seed on crash: Mitigation: seed runs inside the existing advisory-lock + transaction and is
NOT EXISTS-guarded, so a retry completes idempotently (NF2). - Identifier safety regression: Mitigation: reuse
Identifiers.AssertSafe/PgIdentifier; add a negative test (AC6).
- Honour
SchemaNameunconditionally (no opt-in) — rejected in requirements: silently changes behaviour for every operator who already setsSchemaName, breaking FR4. - Dual-read / read-through (no data motion) — detection reads the union of per-schema and legacy default tables forever. Rejected: it permanently couples the tenant to the shared default schema (ongoing read permission; the default table can never be dropped), defeating the isolation/backup-restore boundary that is the feature's purpose.
- Move-with-delete — copy then delete legacy rows. Rejected as default: destructive, and the shared default table may be co-owned by other tenants/processes; deletion risks and permission needs outweigh the benefit of not leaving harmless duplicates. The non-destructive seed (D5) is preferred.
- Manual migration script only — ship a documented SQL script, no automatic seed. Rejected: AC5 requires an automated test of the flip, which a manual script does not satisfy within provisioning; and it pushes correctness onto every operator.
- A dedicated
HistorySchemaResolvervalue object / role — extract resolution into its own type consumed by both runner and helper. Deferred: the runner already owns history-table lifecycle and is the sole resolver; the detection helper is told the result. A base method keeps the responsibility cohesive without adding a type (revisit only if a third consumer appears). - Per-backend resolution (no base method) — let each runner null-coalesce independently. Rejected: duplicates the scope rule across backends and across the write/read split — the exact divergence risk D2 removes.
2026-05-27 (raised in /spec:review tasks, findings #1 and #2; corrections grounded against master):
-
DetectCurrentVersionAsyncis not a history-reading method. D4 originally listed it (and "the versionSELECT") among the methods gaininghistorySchema. In the actual code it reads the box table's column set (MsSqlBoxDetectionHelper.cs:156-180→GetTableColumnsAsHashSetAsync→INFORMATION_SCHEMA.COLUMNS), never__BrighterMigrationHistory. The correct change set is onlyDoesHistoryExistAsyncandGetMaxVersionAsync, both onIAmABoxMigrationDetectionHelper<,>. ConsequentlyIAmAVersionDetectingMigrationHelper<,>does not change for this feature (the earlier text naming it as a structurally-changed interface is withdrawn). -
MSSQL history-existence delegation must also carry the resolved schema.
MsSqlBoxDetectionHelper.DoesHistoryExistAsyncdecides existence by delegating toDoesTableExistAsync(connection, "__BrighterMigrationHistory", DefaultSchemaName, ...)(:85-86) before the COUNT at:93. TheDoesTableExistAsyncsignature is unchanged, but this internal call must passhistorySchema(not the hardcodedDefaultSchemaName); otherwise PerSchema detection short-circuitsfalseand migrations re-run (breaking FR5). PostgreSQL is unaffected — its existence check is inlined with theTABLE_SCHEMAliteral (PostgreSqlBoxDetectionHelper.cs:122), already covered by D4.
These corrections are reflected in specs/0029-multi-tenancy-migrations/tasks.md (S1, T3) and do not change the decision's substance (single resolver, opt-in scope, upgrade-safe default).
2026-10-12 (D5 as implemented, MSSQL and PostgreSQL):
-
The seed runs on every
PerSchemaprovision, not only when the per-schema history table is first created. D5's "One-time" bullet gates the seed on a pre-create probe. That gate is wrong when more than one box shares the per-schema history table: the second box type to flip (e.g. the inbox after the outbox) finds the table already created by the first, skips its seed, and the bootstrap path then stamps a freshbootstrap: detected at V_latestrow — re-running the migration ledger and breaking FR5. The table-level gate is therefore dropped. The seed runs whenever the resolved history schema differs from the backend default; the per-rowNOT EXISTSguard makes a steady-state run a zero-rowINSERT … SELECT, and the cost is that one round-trip per provision. The consequence for the "Cross-schema read permission" risk is that read access to the legacy table is needed for the lifetime of thePerSchemadeployment, not just at flip time. -
A failure to read the legacy history table throws
ConfigurationException. Silently absorbing it would let an empty seed through, and the next provision would bootstrap-stamp a fresh row, again breaking FR5; the throw rolls back the surrounding transaction so no partial per-schema state is left. The catch is filtered to the provider's permission-denied codes (PostgreSQL42501; SQL Server229/230), and wraps both the legacy-existence probe and the seedINSERT … SELECT, so a deadlock, dropped connection or timeout propagates unchanged rather than being misreported as "grant SELECT on the legacy table".
- Requirements: specs/0029-multi-tenancy-migrations/requirements.md
- Related ADRs: 0057 Box Schema Versioning and Migrations (history-table model; Spanner exemption §6), 0058 Box Provisioning RDD Role Interfaces (detection-helper roles), 0059 Box Provisioning Abstract-Base Naming Symmetry (
SqlBoxMigrationRunnerbase) - Issue: #4144 (reviewer item F2-5 on PR #4039)
- Key code:
SqlBoxMigrationRunner.cs:54(base),:339(EnsureHistoryTableAsynchook),:88(Configuration);MsSqlBoxMigrationRunner.cs:50/PostgreSqlBoxMigrationRunner.cs:53(HISTORY_TABLE_SCHEMA);MsSqlBoxDetectionHelper.cs:93(hardcoded read);BoxProvisioningOptions.cs:74(MigrationLockTimeout);ConfigurationException.cs:45((string)ctor);SqlBoxProvisioner.cs:141-169(pre-lock detection call site)