Skip to content

Commit 8d63824

Browse files
d-csclaude
andcommitted
feat(mollifier): make resolveOrgFlag actually org-scoped via Organization.featureFlags
The mollifier gate's resolveOrgFlag was a global feature-flag lookup named as if org-scoped. Phase-1 plan and design doc both intended per-org gating; the implementation regressed because the global flag() helper has no orgId parameter. Adopt the existing per-org feature-flag pattern (used by canAccessAi, canAccessPrivateConnections, compute beta gating): pass `Organization.featureFlags` through as `flag()` overrides. Per-org opt-in now works admin-toggleable via the existing Organization.featureFlags JSON column — no schema migration needed. - mollifierGate: revert resolveFlag/flagEnabled back to resolveOrgFlag/orgFlagEnabled (the name now matches reality). GateInputs gains `orgFeatureFlags`; the default resolver passes them as overrides to `flag()`. - triggerTask.server.ts: thread `environment.organization.featureFlags` into the gate call. - tests: three new postgresTest cases exercise the real DB-backed resolveOrgFlag end-to-end, proving (a) per-org opt-in isolation, (b) unrelated beta flags don't bleed across, (c) per-org overrides take precedence over the global FeatureFlag row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1109b60 commit 8d63824

3 files changed

Lines changed: 186 additions & 8 deletions

File tree

apps/webapp/app/runEngine/services/triggerTask.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,8 @@ export class RunEngineTriggerTaskService {
341341
envId: environment.id,
342342
orgId: environment.organizationId,
343343
taskId,
344+
orgFeatureFlags:
345+
(environment.organization.featureFlags as Record<string, unknown> | null) ?? null,
344346
});
345347

346348
try {

apps/webapp/app/v3/mollifier/mollifierGate.server.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,21 @@ export type GateInputs = {
3434
envId: string;
3535
orgId: string;
3636
taskId: string;
37+
// Org-scoped flag overrides — taken from `Organization.featureFlags` on the
38+
// AuthenticatedEnvironment at the call site. The repo-wide `flag()` helper
39+
// queries the global `FeatureFlag` table; passing per-org overrides lets the
40+
// mollifier opt in a single org without touching the global row, matching
41+
// the pattern used by `canAccessAi`, `canAccessPrivateConnections`, and the
42+
// compute-template beta gate.
43+
orgFeatureFlags: Record<string, unknown> | null;
3744
};
3845

3946
export type TripEvaluator = (inputs: GateInputs) => Promise<TripDecision>;
4047

4148
export type GateDependencies = {
4249
isMollifierEnabled: () => boolean;
4350
isShadowModeOn: () => boolean;
44-
resolveFlag: () => Promise<boolean>;
51+
resolveOrgFlag: (inputs: GateInputs) => Promise<boolean>;
4552
evaluator: TripEvaluator;
4653
logShadow: (
4754
inputs: GateInputs,
@@ -86,8 +93,12 @@ function logDivertDecision(
8693
export const defaultGateDependencies: GateDependencies = {
8794
isMollifierEnabled: () => env.MOLLIFIER_ENABLED === "1",
8895
isShadowModeOn: () => env.MOLLIFIER_SHADOW_MODE === "1",
89-
resolveFlag: () =>
90-
flag({ key: FEATURE_FLAG.mollifierEnabled, defaultValue: false }),
96+
resolveOrgFlag: (inputs) =>
97+
flag({
98+
key: FEATURE_FLAG.mollifierEnabled,
99+
defaultValue: false,
100+
overrides: inputs.orgFeatureFlags ?? {},
101+
}),
91102
evaluator: defaultEvaluator,
92103
logShadow: (inputs, decision) =>
93104
logDivertDecision("mollifier.would_mollify", inputs, decision),
@@ -107,10 +118,10 @@ export async function evaluateGate(
107118
return { action: "pass_through" };
108119
}
109120

110-
const flagEnabled = await d.resolveFlag();
121+
const orgFlagEnabled = await d.resolveOrgFlag(inputs);
111122
const shadowOn = d.isShadowModeOn();
112123

113-
if (!flagEnabled && !shadowOn) {
124+
if (!orgFlagEnabled && !shadowOn) {
114125
d.recordDecision("pass_through");
115126
return { action: "pass_through" };
116127
}
@@ -121,7 +132,7 @@ export async function evaluateGate(
121132
return { action: "pass_through" };
122133
}
123134

124-
if (flagEnabled) {
135+
if (orgFlagEnabled) {
125136
d.logMollified(inputs, decision);
126137
d.recordDecision("mollify", decision.reason);
127138
return { action: "mollify", decision };

apps/webapp/test/mollifierGate.test.ts

Lines changed: 167 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { postgresTest } from "@internal/testcontainers";
12
import { describe, expect, it } from "vitest";
3+
import { FEATURE_FLAG } from "~/v3/featureFlags";
4+
import { makeFlag } from "~/v3/featureFlags.server";
25
import {
36
evaluateGate,
47
type GateDependencies,
@@ -35,7 +38,7 @@ function makeDeps(toggles: Toggles): { deps: GateDependencies; spies: Spies } {
3538
const deps: GateDependencies = {
3639
isMollifierEnabled: () => toggles.enabled,
3740
isShadowModeOn: () => toggles.shadow,
38-
resolveFlag: async () => toggles.flag,
41+
resolveOrgFlag: async () => toggles.flag,
3942
evaluator: async () => {
4043
spies.evaluatorCalls += 1;
4144
return toggles.decision;
@@ -64,7 +67,12 @@ const trippedDecision = {
6467

6568
const passDecision: TripDecision = { divert: false };
6669

67-
const inputs: GateInputs = { envId: "e1", orgId: "o1", taskId: "t1" };
70+
const inputs: GateInputs = {
71+
envId: "e1",
72+
orgId: "o1",
73+
taskId: "t1",
74+
orgFeatureFlags: null,
75+
};
6876

6977
// Cascade truth table. Every combination of (enabled, shadow, flag, divert) is
7078
// enumerated. `evaluatorCalls` is the expected count, not arbitrary: the gate
@@ -164,3 +172,160 @@ describe("evaluateGate cascade — exhaustive truth table", () => {
164172
expect(spies.logMollifiedCalls).toEqual([{ inputs, decision: trippedDecision }]);
165173
});
166174
});
175+
176+
// The gate must opt in single orgs without affecting the others. These tests
177+
// exercise the *real* `resolveOrgFlag` against a real Postgres testcontainer:
178+
// we build it via `makeFlag(prisma)` and let the `Organization.featureFlags`
179+
// blob flow through `flag()`'s overrides path. The global `FeatureFlag` table
180+
// is empty, so the only signal moving outcomes is the per-org JSON.
181+
describe("evaluateGate — per-org isolation via Organization.featureFlags", () => {
182+
function makeIsolationDeps(
183+
realResolveOrgFlag: GateDependencies["resolveOrgFlag"],
184+
): { deps: Partial<GateDependencies>; spies: Spies } {
185+
const spies: Spies = {
186+
evaluatorCalls: 0,
187+
logShadowCalls: [],
188+
logMollifiedCalls: [],
189+
recordDecisionCalls: [],
190+
};
191+
// Override lifecycle bits and inject the real DB-backed resolveOrgFlag.
192+
// Evaluator returns a fixed tripped decision so the outcome is purely a
193+
// function of the flag resolution (which is what we're isolating on).
194+
const deps: Partial<GateDependencies> = {
195+
isMollifierEnabled: () => true,
196+
isShadowModeOn: () => false,
197+
resolveOrgFlag: realResolveOrgFlag,
198+
evaluator: async () => {
199+
spies.evaluatorCalls += 1;
200+
return trippedDecision;
201+
},
202+
logShadow: (inputs, decision) => {
203+
spies.logShadowCalls.push({ inputs, decision });
204+
},
205+
logMollified: (inputs, decision) => {
206+
spies.logMollifiedCalls.push({ inputs, decision });
207+
},
208+
recordDecision: (outcome, reason) => {
209+
spies.recordDecisionCalls.push({ outcome, reason });
210+
},
211+
};
212+
return { deps, spies };
213+
}
214+
215+
// Build the production resolveOrgFlag wired to the test Prisma client. This
216+
// is exactly the closure `defaultGateDependencies.resolveOrgFlag` runs in
217+
// prod — the only swap is the Prisma instance.
218+
function realResolveOrgFlag(prisma: Parameters<typeof makeFlag>[0]) {
219+
const f = makeFlag(prisma);
220+
return (inputs: GateInputs) =>
221+
f({
222+
key: FEATURE_FLAG.mollifierEnabled,
223+
defaultValue: false,
224+
overrides: inputs.orgFeatureFlags ?? {},
225+
});
226+
}
227+
228+
postgresTest(
229+
"opts in only the org whose featureFlags has mollifierEnabled=true",
230+
async ({ prisma }) => {
231+
const resolve = realResolveOrgFlag(prisma);
232+
const orgA = { ...inputs, orgId: "org_a", orgFeatureFlags: { mollifierEnabled: true } };
233+
const orgB = { ...inputs, orgId: "org_b", orgFeatureFlags: { mollifierEnabled: false } };
234+
const orgC = { ...inputs, orgId: "org_c", orgFeatureFlags: null };
235+
236+
const a = makeIsolationDeps(resolve);
237+
const b = makeIsolationDeps(resolve);
238+
const c = makeIsolationDeps(resolve);
239+
240+
const [outcomeA, outcomeB, outcomeC] = await Promise.all([
241+
evaluateGate(orgA, a.deps),
242+
evaluateGate(orgB, b.deps),
243+
evaluateGate(orgC, c.deps),
244+
]);
245+
246+
// Only org A's flag is on → only org A mollifies. Orgs B and C never
247+
// reach the evaluator because both flag and shadow-mode are off.
248+
expect(outcomeA.action).toBe("mollify");
249+
expect(outcomeB.action).toBe("pass_through");
250+
expect(outcomeC.action).toBe("pass_through");
251+
252+
expect(a.spies.evaluatorCalls).toBe(1);
253+
expect(b.spies.evaluatorCalls).toBe(0);
254+
expect(c.spies.evaluatorCalls).toBe(0);
255+
256+
expect(a.spies.logMollifiedCalls).toHaveLength(1);
257+
expect(b.spies.logMollifiedCalls).toHaveLength(0);
258+
expect(c.spies.logMollifiedCalls).toHaveLength(0);
259+
},
260+
);
261+
262+
postgresTest(
263+
"another org's beta flags must not opt them into mollifier",
264+
async ({ prisma }) => {
265+
const resolve = realResolveOrgFlag(prisma);
266+
// Org A has mollifier on (plus an unrelated beta).
267+
const orgA = {
268+
...inputs,
269+
orgId: "org_a",
270+
orgFeatureFlags: { mollifierEnabled: true, hasComputeAccess: true },
271+
};
272+
// Org B has *other* betas on but mollifier remains off — keys that gate
273+
// compute/AI/query must not bleed across into the mollifier decision.
274+
const orgB = {
275+
...inputs,
276+
orgId: "org_b",
277+
orgFeatureFlags: { hasComputeAccess: true, hasAiAccess: true },
278+
};
279+
280+
const a = makeIsolationDeps(resolve);
281+
const b = makeIsolationDeps(resolve);
282+
283+
const outcomeA = await evaluateGate(orgA, a.deps);
284+
const outcomeB = await evaluateGate(orgB, b.deps);
285+
286+
expect(outcomeA.action).toBe("mollify");
287+
expect(outcomeB.action).toBe("pass_through");
288+
},
289+
);
290+
291+
postgresTest(
292+
"global FeatureFlag row enables only when an org's overrides don't say otherwise",
293+
async ({ prisma }) => {
294+
// Set the global flag on. The repo-wide `flag()` helper checks
295+
// overrides first, then global, then default. So:
296+
// - org with explicit `mollifierEnabled: false` → stays off.
297+
// - org with no override → picks up the global on.
298+
// - org with explicit `true` → on.
299+
await prisma.featureFlag.create({
300+
data: { key: FEATURE_FLAG.mollifierEnabled, value: true },
301+
});
302+
const resolve = realResolveOrgFlag(prisma);
303+
304+
const orgOptedOut = {
305+
...inputs,
306+
orgId: "org_opted_out",
307+
orgFeatureFlags: { mollifierEnabled: false },
308+
};
309+
const orgInherits = { ...inputs, orgId: "org_inherits", orgFeatureFlags: null };
310+
const orgExplicit = {
311+
...inputs,
312+
orgId: "org_explicit",
313+
orgFeatureFlags: { mollifierEnabled: true },
314+
};
315+
316+
const optedOut = makeIsolationDeps(resolve);
317+
const inherits = makeIsolationDeps(resolve);
318+
const explicit = makeIsolationDeps(resolve);
319+
320+
const [outOptedOut, outInherits, outExplicit] = await Promise.all([
321+
evaluateGate(orgOptedOut, optedOut.deps),
322+
evaluateGate(orgInherits, inherits.deps),
323+
evaluateGate(orgExplicit, explicit.deps),
324+
]);
325+
326+
expect(outOptedOut.action).toBe("pass_through");
327+
expect(outInherits.action).toBe("mollify");
328+
expect(outExplicit.action).toBe("mollify");
329+
},
330+
);
331+
});

0 commit comments

Comments
 (0)