1010 * governing permissions and limitations under the License.
1111 */
1212
13- import { readFeatureFlag } from './feature-flags-storage.js' ;
13+ import { readFeatureFlag , upsertFeatureFlag } from './feature-flags-storage.js' ;
1414
1515export const LLMO_FEATURE_FLAG_PRODUCT = 'LLMO' ;
1616export const LLMO_BRANDALF_FLAG = 'brandalf' ;
1717export const LLMO_ONBOARDING_MODE_V1 = 'v1' ;
1818export const LLMO_ONBOARDING_MODE_V2 = 'v2' ;
1919
20- export function normalizeLlmoOnboardingMode ( mode ) {
21- return mode === LLMO_ONBOARDING_MODE_V2 ? LLMO_ONBOARDING_MODE_V2 : LLMO_ONBOARDING_MODE_V1 ;
22- }
20+ /**
21+ * Brandalf GA cutoff in Unix epoch milliseconds (2026-04-01T00:00:00Z).
22+ * Any site whose createdAt is strictly before this value is treated as v1 (legacy).
23+ * Override per-environment via LLMO_BRANDALF_GA_CUTOFF_MS without a full redeploy.
24+ *
25+ * TEMPORARY — remove once all v1 customers have been migrated to v2.
26+ */
27+ export const LLMO_BRANDALF_GA_CUTOFF_MS_DEFAULT = Date . UTC ( 2026 , 3 , 1 ) ;
2328
2429export async function readBrandalfFlagOverride ( organizationId , postgrestClient ) {
2530 if ( ! organizationId || ! postgrestClient ?. from ) {
@@ -34,31 +39,170 @@ export async function readBrandalfFlagOverride(organizationId, postgrestClient)
3439 } ) ;
3540}
3641
42+ /**
43+ * Resolves the Brandalf GA cutoff from the environment (epoch ms).
44+ * Falls back to LLMO_BRANDALF_GA_CUTOFF_MS_DEFAULT if the env var is missing or invalid.
45+ *
46+ * TEMPORARY — remove once all v1 customers have been migrated to v2.
47+ *
48+ * @param {object } context - Request context
49+ * @returns {number } Cutoff timestamp in milliseconds
50+ */
51+ export function resolveBrandalfCutoffMs ( context ) {
52+ const raw = context ?. env ?. LLMO_BRANDALF_GA_CUTOFF_MS ;
53+ if ( raw === undefined || raw === null || raw === '' ) {
54+ return LLMO_BRANDALF_GA_CUTOFF_MS_DEFAULT ;
55+ }
56+ const parsed = Number ( raw ) ;
57+ if ( ! Number . isFinite ( parsed ) || parsed <= 0 ) {
58+ context ?. log ?. warn ?. (
59+ `Invalid LLMO_BRANDALF_GA_CUTOFF_MS "${ raw } ", using default ${ LLMO_BRANDALF_GA_CUTOFF_MS_DEFAULT } ` ,
60+ ) ;
61+ return LLMO_BRANDALF_GA_CUTOFF_MS_DEFAULT ;
62+ }
63+ return parsed ;
64+ }
65+
66+ /**
67+ * Returns true if the organization has any site whose createdAt is strictly before
68+ * the resolved cutoff. Sites with missing or unparseable createdAt are ignored (not
69+ * treated as legacy) to avoid false positives, but logged so monitoring can pick up
70+ * data-quality issues — silently swallowing them would bias the safeguard toward v2.
71+ *
72+ * TEMPORARY — remove once all v1 customers have been migrated to v2.
73+ *
74+ * @param {string } organizationId
75+ * @param {object } context - Request context (must have context.dataAccess.Site)
76+ * @returns {Promise<boolean> }
77+ */
78+ export async function hasPreBrandalfSites ( organizationId , context ) {
79+ const cutoffMs = resolveBrandalfCutoffMs ( context ) ;
80+ const { Site } = context . dataAccess ;
81+ const log = context ?. log ;
82+ const sites = await Site . allByOrganizationId ( organizationId ) ;
83+ return sites . some ( ( s ) => {
84+ const createdAt = s . getCreatedAt ?. ( ) ;
85+ if ( createdAt === null || createdAt === undefined ) {
86+ log ?. warn ?. (
87+ `Site ${ s . getId ?. ( ) ?? '<unknown>' } in org ${ organizationId } has no createdAt — skipping legacy check` ,
88+ ) ;
89+ return false ;
90+ }
91+ const ts = createdAt instanceof Date
92+ ? createdAt . getTime ( )
93+ : new Date ( createdAt ) . getTime ( ) ;
94+ if ( ! Number . isFinite ( ts ) ) {
95+ log ?. warn ?. (
96+ `Site ${ s . getId ?. ( ) ?? '<unknown>' } in org ${ organizationId } has unparseable createdAt "${ createdAt } " — skipping legacy check` ,
97+ ) ;
98+ return false ;
99+ }
100+ return ts < cutoffMs ;
101+ } ) ;
102+ }
103+
104+ /**
105+ * Resolves the LLMO onboarding mode (v1 or v2) for the given organization.
106+ *
107+ * Decision order (see decision matrix in v1-v2-onboarding-consistency-safeguard.md):
108+ * 1. If brandalf=true on the org:
109+ * a. If kill switch is v1 AND org has pre-cutoff sites → revert brandalf
110+ * flag to false, log warning, return v1 (row 1 remediation).
111+ * b. Otherwise → return v2 (rows 3, 5, 7).
112+ * 2. If LLMO_ONBOARDING_DEFAULT_VERSION is 'v1' → return v1 (kill switch, rows 2, 4).
113+ * 3. If org has pre-cutoff sites → return v1 (legacy protection, row 6).
114+ * 4. Otherwise → return v2 (new customer default, row 8).
115+ *
116+ * TEMPORARY — should be removed once all v1 customers have been migrated to v2.
117+ *
118+ * @param {string } organizationId
119+ * @param {object } context - Request context
120+ * @returns {Promise<'v1'|'v2'> }
121+ */
37122export async function resolveLlmoOnboardingMode ( organizationId , context ) {
38- const configuredDefault = context ?. env ?. LLMO_ONBOARDING_DEFAULT_VERSION ;
39- const defaultMode = normalizeLlmoOnboardingMode ( configuredDefault ) ;
40123 const { log = console } = context || { } ;
41124 const postgrestClient = context ?. dataAccess ?. services ?. postgrestClient ;
42125
43- if ( configuredDefault && configuredDefault !== defaultMode ) {
126+ // 1. Brandalf flag check: if the org has brandalf=true, it has been
127+ // explicitly migrated to v2. Honor it — except when the kill switch
128+ // is active AND the org has pre-cutoff sites (row 1 remediation).
129+ let brandalfEnabled = false ;
130+ try {
131+ brandalfEnabled = await readBrandalfFlagOverride ( organizationId , postgrestClient ) === true ;
132+ } catch ( flagError ) {
44133 log . warn (
45- `Invalid LLMO_ONBOARDING_DEFAULT_VERSION " ${ configuredDefault } ", falling back to ${ defaultMode } ` ,
134+ `Failed to read brandalf flag for org ${ organizationId } : ${ flagError . message } — proceeding with default resolution ` ,
46135 ) ;
47136 }
48137
49- try {
50- const override = await readBrandalfFlagOverride ( organizationId , postgrestClient ) ;
51- if ( override === true ) {
52- return LLMO_ONBOARDING_MODE_V2 ;
138+ if ( brandalfEnabled ) {
139+ const configuredDefault = context ?. env ?. LLMO_ONBOARDING_DEFAULT_VERSION ;
140+
141+ // Row 1: kill switch active + pre-cutoff sites + brandalf=true
142+ // → revert flag to false and force v1.
143+ if ( configuredDefault === LLMO_ONBOARDING_MODE_V1 ) {
144+ try {
145+ if ( await hasPreBrandalfSites ( organizationId , context ) ) {
146+ try {
147+ await upsertFeatureFlag ( {
148+ organizationId,
149+ product : LLMO_FEATURE_FLAG_PRODUCT ,
150+ flagName : LLMO_BRANDALF_FLAG ,
151+ value : false ,
152+ updatedBy : 'llmo-onboarding-mode-resolution' ,
153+ postgrestClient,
154+ } ) ;
155+ log . warn (
156+ `LLMO mode resolution: organization ${ organizationId } has brandalf=true but also has `
157+ + 'pre-cutoff sites while kill switch is active. Reverted brandalf flag to false. '
158+ + 'This org has sites that require migration before it can use v2.' ,
159+ ) ;
160+ } catch ( revertError ) {
161+ log . error (
162+ `Failed to revert brandalf flag for org ${ organizationId } : ${ revertError . message } . `
163+ + 'Flag may still be true — manual intervention required.' ,
164+ ) ;
165+ }
166+ return LLMO_ONBOARDING_MODE_V1 ;
167+ }
168+ } catch ( error ) {
169+ log . warn (
170+ `Failed to check pre-Brandalf sites for org ${ organizationId } : ${ error . message } ` ,
171+ ) ;
172+ // Cannot confirm pre-cutoff sites — fall through to v2
173+ // (brandalf=true is still set, so honor the migration).
174+ }
53175 }
54- if ( override === false ) {
176+
177+ // Rows 3, 5, 7: brandalf=true without row-1 condition → v2.
178+ log . info (
179+ `LLMO mode resolution: organization ${ organizationId } has brandalf=true — using v2` ,
180+ ) ;
181+ return LLMO_ONBOARDING_MODE_V2 ;
182+ }
183+
184+ // 2. Environment-level default (brandalf is false/missing from here on).
185+ // 'v1' is the global kill switch; anything else defaults to v2.
186+ const configuredDefault = context ?. env ?. LLMO_ONBOARDING_DEFAULT_VERSION ;
187+ if ( configuredDefault === LLMO_ONBOARDING_MODE_V1 ) {
188+ return LLMO_ONBOARDING_MODE_V1 ;
189+ }
190+ if ( configuredDefault && configuredDefault !== LLMO_ONBOARDING_MODE_V2 ) {
191+ log . warn (
192+ `Invalid LLMO_ONBOARDING_DEFAULT_VERSION "${ configuredDefault } ", falling back to ${ LLMO_ONBOARDING_MODE_V2 } ` ,
193+ ) ;
194+ }
195+
196+ // 3. Protect legacy customers: any org with a pre-cutoff site stays on v1.
197+ try {
198+ if ( await hasPreBrandalfSites ( organizationId , context ) ) {
55199 return LLMO_ONBOARDING_MODE_V1 ;
56200 }
57201 } catch ( error ) {
58202 log . warn (
59- `Failed to resolve brandalf feature flag for organization ${ organizationId } : ${ error . message } ` ,
203+ `Failed to check pre-Brandalf sites for organization ${ organizationId } : ${ error . message } ` ,
60204 ) ;
61205 }
62206
63- return defaultMode ;
207+ return LLMO_ONBOARDING_MODE_V2 ;
64208}
0 commit comments