---
apiVersion: data-spec/v1
id: normalize-tenant-flow-source-20260428
title: "Normalize Flow.Source='tenant' to 'custom' on upgrade (runs in bootstrap, NOT as SQL spec)"
description: "Audit-side documentation for the one-shot Go migration that rewrites every existing Flow row with the legacy Source='tenant' value to Source='custom'. The 'tenant' value was a short-lived branch-local discriminator added by #2135 and removed by #2173; it is not a valid Salesforce Flow.Source enum (only 'standard' and 'custom' are valid per SF API v63.0). The executable path lives in migrateTenantFlowSourceNormalize (backend/internal/bootstrap/standard_flows.go), gated by _System_BootstrapState row 'tenant_flow_source_normalize_v1'. This file is kept as a paper trail, NOT as an apply target."
issue: "P1 finding on PR #2173 from IANTHEREAL gate-review"
created: 2026-04-28
scope: both
status: runs-in-bootstrap
tables_modified:
  - Flow
tables: [Flow]
idempotent: true
risk: low
reviewers: ["@codeowners"]
depends_on: []
---

# Normalize `Flow.Source` = 'tenant' → 'custom'

**Status: not applied via data-spec runner. The actual normalization lives
in Go bootstrap code at
`backend/internal/bootstrap/standard_flows.go:migrateTenantFlowSourceNormalize`,
gated by the `_System_BootstrapState` row
`tenant_flow_source_normalize_v1`. This file is retained as an audit
artifact documenting what that migration does and why.**

## Why this exists

PR #2135 briefly introduced `Flow.Source='tenant'` as a branch-local
discriminator for tenant-overlay flow versions, separate from `'standard'`
(L1 platform-shipped) and `'custom'` (L2 admin-authored). PR #2173
recognized this as a Salesforce contract violation:

> Per SF API v63.0, `Flow.Source` is restricted to `'standard'` and
> `'custom'`. There is no `'tenant'` value. Tenant overlays — being
> org-authored runtime metadata equivalent to admin-created custom flows —
> belong under `'custom'`.

PR #2173 fixed the **write path** (every new tenant-overlay flow version
written by reconciliation now uses `'custom'`) but did not repair rows
already written by upgraded clusters during the brief #2135 window.
IANTHEREAL flagged this as a P1 blocker on #2173:

> Existing tenants can continue to expose `Flow.Source='tenant'` after this
> PR, so the Salesforce API v63.0 contract remains broken for upgraded
> data. If `apply-tenant` is not rerun after deploy, even the active
> version can stay invalid. If it is rerun, old versions still retain the
> invalid enum value and any query/tooling path inspecting Flow versions
> can observe it.

`migrateTenantFlowSourceNormalize` closes that gap on the **normal**
upgrade path (every boot, not only `cmd/setup apply-tenant`).

## Why this is unconditional UPDATE — no IsCustomized / Metadata-byte gate

`migrateFlowSourceBackfill` carefully gates on:

- byte-equality of `Flow.Metadata` against the shipped pack, and
- `FlowDefinition.IsCustomized` being false.

Those gates exist because flipping `'custom'` → `'standard'` is
**escalation-of-ownership** — it could re-claim an admin-edited row as
platform metadata.

This migration is the opposite direction (de-escalation `'tenant'` →
`'custom'`) and there is no failure mode where a `'tenant'` row should be
preserved:

| Source of `Flow.Source='tenant'` row | Correct outcome |
|---|---|
| Written by #2135 tenant-overlay reconcile | flip to `'custom'` (was always meant to mean "L2 custom") |
| Hand-written by an admin via API | impossible — `'tenant'` is not a valid public Flow.Source enum, the SF write path rejects it |
| Loaded by an external tool | impossible — tools target the SF v63.0 contract which lacks `'tenant'` |

So a single `UPDATE Flow SET Source='custom' WHERE Source='tenant'` is
both safe and correct.

## Why this is in bootstrap (not a SQL data-spec or `apply-tenant` only)

Per CLAUDE.md "Legacy remediation belongs in data specs": the long-term
home of legacy-row repair is `data-specs/`. This file is the audit-side
record. The executable migration runs in Go bootstrap rather than the
SQL-pure data-spec runner because:

1. The repair must execute on the **normal upgrade path**, not only on
   `cmd/setup apply-tenant`. Bootstrap runs on every boot; the data-spec
   runner is invoked from `cmd/setup apply` which is not on the normal
   server-start path.
2. The repair is one-shot and gated by a `_System_BootstrapState` row, so
   re-runs are O(1) (single SELECT on the gate; UPDATE is skipped).

This matches the precedent of `migrateFlowSourceBackfill` /
`migrateHistoricalFlowSourceBackfill`, which also live in bootstrap with
audit-side data-specs (`data-specs/2026-04-23e-backfill-flow-source.md`).

## Idempotency

- First run: gate row absent → UPDATE runs → gate stamped `applied`.
- Subsequent runs: gate row reads `applied` → migration returns
  immediately, no SELECT/UPDATE on `Flow`.
- If the UPDATE fails mid-flight (e.g. lock wait timeout), the gate row
  remains absent → next boot retries from scratch. Safe because the
  `WHERE Source='tenant'` filter automatically skips already-flipped
  rows.

## Equivalent SQL (for documentation only — DO NOT RUN MANUALLY)

```sql
UPDATE Flow SET Source = 'custom', LastModifiedDate = NOW() WHERE Source = 'tenant';
```

The Go migration sets `LastModifiedDate` to the bootstrap-supplied
timestamp for run reproducibility; manual `NOW()` is fine for
DBA-side post-mortem only.

## Verification

After bootstrap, the following must hold on every cluster:

```sql
SELECT COUNT(*) FROM Flow WHERE Source = 'tenant';   -- expect 0
SELECT Status FROM _System_BootstrapState WHERE StateKey = 'tenant_flow_source_normalize_v1';
                                                     -- expect 'applied'
```

A regression test asserting these invariants lives at
`backend/internal/bootstrap/standard_flows_test.go`:
- `TestMigrateTenantFlowSourceNormalize_FlipsTenantToCustom`
- `TestMigrateTenantFlowSourceNormalize_GateSkipsWhenApplied`
- `TestMigrateTenantFlowSourceNormalize_GateNotStampedOnUpdateFailure`
