Fas 1a punkt 2: release_gates tabell + go/no-go-vy

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 23:10:09 +07:00
parent 1250640b1d
commit d1455fb581
16 changed files with 10105 additions and 0 deletions
+1
View File
@@ -2,3 +2,4 @@ export * from "./client.js";
export * as schema from "./schema/index.js";
export * from "./schema/index.js";
export * from "./analytics-gdpr.js";
export * from "./release-gates.js";
+520
View File
@@ -0,0 +1,520 @@
/**
* Release gate evaluator (spec §17).
* Computes go/no-go status from product_analytics_events.
*/
import { sql } from "drizzle-orm";
import type { Database } from "./client.js";
import { schema } from "./index.js";
import {
BUILT_IN_RELEASE_GATES,
RELEASE_GATE_STATUSES,
type ReleaseGateComparison,
type ReleaseGateStatus,
} from "@app/shared-types";
export interface GateEvaluation {
gateKey: string;
category: string;
nameSv: string;
nameEn: string;
targetValue: number;
comparison: ReleaseGateComparison;
value: number | null;
status: ReleaseGateStatus;
lastEvaluatedAt: string;
blocking: boolean;
notes?: string;
}
function compare(value: number, target: number, comparison: ReleaseGateComparison): boolean {
if (comparison === "gte") return value >= target;
if (comparison === "lte") return value <= target;
return Math.abs(value - target) < Number.EPSILON;
}
function decideStatus(
value: number | null,
target: number,
comparison: ReleaseGateComparison,
measurable = true,
): ReleaseGateStatus {
if (!measurable || value === null || Number.isNaN(value)) return "not_measurable";
return compare(value, target, comparison) ? "passed" : "failed";
}
/**
* Seed the built-in release gates from §17. Idempotent existing rows are
* left untouched so admin tuning is preserved.
*/
export async function seedReleaseGates(db: Database): Promise<void> {
for (const gate of BUILT_IN_RELEASE_GATES) {
await db
.insert(schema.releaseGates)
.values({
gateKey: gate.gateKey,
category: gate.category,
nameSv: gate.nameSv,
nameEn: gate.nameEn,
descriptionSv: gate.descriptionSv,
descriptionEn: gate.descriptionEn,
targetValue: gate.targetValue,
comparison: gate.comparison,
evaluationWindowDays: gate.evaluationWindowDays,
blocking: gate.blocking,
status: "pending",
})
.onConflictDoNothing({ target: schema.releaseGates.gateKey });
}
}
/**
* Evaluate all release gates and persist results.
*/
export async function evaluateReleaseGates(db: Database): Promise<GateEvaluation[]> {
const gates = await db.select().from(schema.releaseGates).orderBy(schema.releaseGates.gateKey);
const now = new Date();
const results: GateEvaluation[] = [];
for (const gate of gates) {
const value = await evaluateGate(db, gate.gateKey, gate.evaluationWindowDays);
const status = decideStatus(value, gate.targetValue, gate.comparison, value !== null);
const updated = await db
.update(schema.releaseGates)
.set({
lastValue: value,
lastEvaluatedAt: now,
status,
updatedAt: now,
})
.where(sql`${schema.releaseGates.id} = ${gate.id}`)
.returning();
const row = updated[0] ?? gate;
results.push({
gateKey: row.gateKey,
category: row.category,
nameSv: row.nameSv,
nameEn: row.nameEn,
targetValue: row.targetValue,
comparison: row.comparison,
value: row.lastValue,
status: row.status,
lastEvaluatedAt: row.lastEvaluatedAt?.toISOString() ?? now.toISOString(),
blocking: row.blocking,
notes: row.notes ?? undefined,
});
}
return results;
}
/**
* Evaluate a single gate by key. Returns null if not measurable yet.
*/
export async function evaluateGate(
db: Database,
gateKey: string,
windowDays: number,
): Promise<number | null> {
const end = new Date();
const start = new Date();
start.setUTCDate(start.getUTCDate() - windowDays);
const startIso = start.toISOString();
const endIso = end.toISOString();
switch (gateKey) {
case "first_scan_rate":
return userConversion(db, "account_created", "scan_completed", startIso, endIso);
case "recipe_view_rate":
return userConversion(db, "account_created", "recommendations_viewed", startIso, endIso);
case "recipe_save_or_cook_day0":
return sameDayConversion(db, ["recipe_saved", "cooking_session_started"], startIso, endIso);
case "second_inventory_event_week1":
return repeatedEventRate(
db,
["inventory_item_added", "inventory_item_consumed", "inventory_item_discarded"],
2,
startIso,
endIso,
);
case "household_collaboration_rate":
return householdCollaborationRate(db, startIso, endIso);
case "weekly_trusted_meal_week2":
return weeklyTrustedMealWeek2(db, startIso, endIso);
case "critical_allergy_errors":
// No dedicated event yet; report not_measurable until §5 allergy trust is built.
return null;
case "correction_rate":
return correctionRate(db, startIso, endIso);
case "sync_conflict_resolution_rate":
return conflictResolutionRate(db, startIso, endIso);
case "retention_d1":
return cohortRetention(db, 1, startIso, endIso);
case "retention_d7":
return cohortRetention(db, 7, startIso, endIso);
case "retention_d30":
return cohortRetention(db, 30, startIso, endIso);
case "retention_week4_household":
return householdWeekRetention(db, 4, startIso, endIso);
case "paid_month2_retention":
// Requires subscription lifecycle data not yet collected in analytics events.
return null;
case "trial_to_paid_rate":
return userConversion(db, "trial_started", "subscription_started", startIso, endIso);
case "refund_rate":
// Requires refund events from billing provider.
return null;
case "monthly_churn":
// Requires subscription status history.
return null;
default:
return null;
}
}
// ---------------------------------------------------------------------------
// Metric helpers
// ---------------------------------------------------------------------------
async function userConversion(
db: Database,
fromEvent: string,
toEvent: string,
startIso: string,
endIso: string,
): Promise<number | null> {
const raw = await db.execute(sql`
WITH base AS (
SELECT DISTINCT user_id
FROM product_analytics_events
WHERE event_name = ${fromEvent}
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
AND user_id IS NOT NULL
),
converted AS (
SELECT DISTINCT b.user_id
FROM base b
INNER JOIN product_analytics_events e ON e.user_id = b.user_id
WHERE e.event_name = ${toEvent}
AND e.occurred_at >= ${startIso}
AND e.occurred_at < ${endIso}
)
SELECT
(SELECT count(*) FROM base) AS total,
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
function inClause(values: string[]) {
return sql`(${sql.join(values.map((v) => sql`${v}`), sql`, `)})`;
}
async function sameDayConversion(
db: Database,
toEvents: string[],
startIso: string,
endIso: string,
): Promise<number | null> {
const raw = await db.execute(sql`
WITH base AS (
SELECT user_id, (occurred_at AT TIME ZONE 'UTC')::date AS cohort_date
FROM product_analytics_events
WHERE event_name = 'account_created'
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
AND user_id IS NOT NULL
),
converted AS (
SELECT DISTINCT b.user_id
FROM base b
INNER JOIN product_analytics_events e ON e.user_id = b.user_id
WHERE e.event_name IN ${inClause(toEvents)}
AND e.occurred_at >= ${startIso}
AND e.occurred_at < ${endIso}
AND (e.occurred_at AT TIME ZONE 'UTC')::date = b.cohort_date
)
SELECT
(SELECT count(DISTINCT user_id) FROM base) AS total,
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
async function repeatedEventRate(
db: Database,
events: string[],
minOccurrences: number,
startIso: string,
endIso: string,
): Promise<number | null> {
const raw = await db.execute(sql`
WITH base AS (
SELECT DISTINCT user_id
FROM product_analytics_events
WHERE event_name = 'account_created'
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
AND user_id IS NOT NULL
),
event_counts AS (
SELECT b.user_id, count(*) AS n
FROM base b
INNER JOIN product_analytics_events e ON e.user_id = b.user_id
WHERE e.event_name IN ${inClause(events)}
AND e.occurred_at >= ${startIso}
AND e.occurred_at < ${endIso}
GROUP BY b.user_id
HAVING count(*) >= ${minOccurrences}
)
SELECT
(SELECT count(*) FROM base) AS total,
(SELECT count(*) FROM event_counts) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
async function householdCollaborationRate(
db: Database,
startIso: string,
endIso: string,
): Promise<number | null> {
const raw = await db.execute(sql`
WITH base AS (
SELECT DISTINCT household_id
FROM product_analytics_events
WHERE event_name = 'household_created'
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
AND household_id IS NOT NULL
),
collaborative AS (
SELECT DISTINCT b.household_id
FROM base b
INNER JOIN product_analytics_events e ON e.household_id = b.household_id
WHERE e.event_name IN ('household_invite_sent', 'household_invite_accepted', 'second_member_first_action')
AND e.occurred_at >= ${startIso}
AND e.occurred_at < ${endIso}
)
SELECT
(SELECT count(*) FROM base) AS total,
(SELECT count(*) FROM collaborative) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
async function weeklyTrustedMealWeek2(
db: Database,
startIso: string,
endIso: string,
): Promise<number | null> {
const raw = await db.execute(sql`
WITH activated AS (
SELECT DISTINCT household_id,
min(occurred_at) AS first_activated_at
FROM product_analytics_events
WHERE event_name = 'cooking_session_completed'
AND household_id IS NOT NULL
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
GROUP BY household_id
),
week2_cooks AS (
SELECT DISTINCT a.household_id
FROM activated a
INNER JOIN product_analytics_events e
ON e.household_id = a.household_id
AND e.event_name = 'cooking_session_completed'
AND e.occurred_at >= a.first_activated_at + INTERVAL '7 days'
AND e.occurred_at < a.first_activated_at + INTERVAL '14 days'
)
SELECT
(SELECT count(*) FROM activated) AS total,
(SELECT count(*) FROM week2_cooks) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
async function correctionRate(
db: Database,
startIso: string,
endIso: string,
): Promise<number | null> {
const raw = await db.execute(sql`
SELECT
count(*) FILTER (WHERE event_name = 'scan_item_corrected') AS corrected,
count(*) FILTER (WHERE event_name IN ('scan_item_confirmed', 'scan_item_corrected')) AS total
FROM product_analytics_events
WHERE event_name IN ('scan_item_confirmed', 'scan_item_corrected')
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
corrected: number;
total: number;
} | undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.corrected) / Number(row.total);
}
async function conflictResolutionRate(
db: Database,
startIso: string,
endIso: string,
): Promise<number | null> {
const raw = await db.execute(sql`
SELECT
count(*) FILTER (WHERE event_name = 'inventory_conflict_resolved') AS resolved,
count(*) FILTER (WHERE event_name IN ('inventory_conflict_created', 'inventory_conflict_resolved')) AS total
FROM product_analytics_events
WHERE event_name IN ('inventory_conflict_created', 'inventory_conflict_resolved')
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
resolved: number;
total: number;
} | undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.resolved) / Number(row.total);
}
async function cohortRetention(
db: Database,
day: number,
startIso: string,
endIso: string,
): Promise<number | null> {
const raw = await db.execute(sql`
WITH cohort AS (
SELECT DISTINCT user_id, (occurred_at AT TIME ZONE 'UTC')::date AS cohort_date
FROM product_analytics_events
WHERE event_name = 'account_created'
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
AND user_id IS NOT NULL
),
active AS (
SELECT DISTINCT c.user_id
FROM cohort c
INNER JOIN product_analytics_events e ON e.user_id = c.user_id
WHERE (e.occurred_at AT TIME ZONE 'UTC')::date = c.cohort_date + ${day}::integer
)
SELECT
(SELECT count(DISTINCT user_id) FROM cohort) AS total,
(SELECT count(*) FROM active) AS active
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
active: number;
} | undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.active) / Number(row.total);
}
async function householdWeekRetention(
db: Database,
week: number,
startIso: string,
endIso: string,
): Promise<number | null> {
const raw = await db.execute(sql`
WITH cohort AS (
SELECT DISTINCT household_id,
(occurred_at AT TIME ZONE 'UTC')::date AS cohort_date
FROM product_analytics_events
WHERE event_name = 'household_created'
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
AND household_id IS NOT NULL
),
active AS (
SELECT DISTINCT c.household_id
FROM cohort c
INNER JOIN product_analytics_events e ON e.household_id = c.household_id
WHERE (e.occurred_at AT TIME ZONE 'UTC')::date >= c.cohort_date + ${(week - 1) * 7}::integer
AND (e.occurred_at AT TIME ZONE 'UTC')::date < c.cohort_date + ${week * 7}::integer
)
SELECT
(SELECT count(DISTINCT household_id) FROM cohort) AS total,
(SELECT count(*) FROM active) AS active
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
active: number;
} | undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.active) / Number(row.total);
}
/**
* Summarise the current go/no-go verdict across all gates.
*/
export function summarizeReleaseGates(gates: GateEvaluation[]): {
overall: "go" | "no_go" | "pending";
passed: number;
failed: number;
blocked: number;
pending: number;
notMeasurable: number;
} {
let failed = 0;
let blocked = 0;
let pending = 0;
let notMeasurable = 0;
let passed = 0;
for (const g of gates) {
if (g.status === "passed") passed++;
else if (g.status === "failed") {
failed++;
if (g.blocking) blocked++;
} else if (g.status === "not_measurable") notMeasurable++;
else pending++;
}
const overall = blocked > 0 ? "no_go" : failed > 0 ? "no_go" : pending > 0 ? "pending" : "go";
return { overall, passed, failed, blocked, pending, notMeasurable };
}
export { RELEASE_GATE_STATUSES };
+1
View File
@@ -17,3 +17,4 @@ export * from "./seasons.js";
export * from "./subscriptions.js";
export * from "./analytics.js";
export * from "./platform.js";
export * from "./releaseGates.js";
@@ -0,0 +1,57 @@
/**
* Release gates schema (spec §17).
* Configurable go/no-go criteria evaluated against analytics events.
*/
import { boolean, doublePrecision, index, integer, pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import { createdAt, updatedAt } from "./_shared.js";
import {
RELEASE_GATE_CATEGORIES,
RELEASE_GATE_COMPARISONS,
RELEASE_GATE_STATUSES,
tuple,
} from "@app/shared-types";
export const releaseGateCategoryEnum = pgEnum(
"release_gate_category",
tuple(RELEASE_GATE_CATEGORIES),
);
export const releaseGateComparisonEnum = pgEnum(
"release_gate_comparison",
tuple(RELEASE_GATE_COMPARISONS),
);
export const releaseGateStatusEnum = pgEnum(
"release_gate_status",
tuple(RELEASE_GATE_STATUSES),
);
export const releaseGates = pgTable(
"release_gates",
{
id: uuid("id").primaryKey().defaultRandom(),
gateKey: varchar("gate_key", { length: 64 }).notNull().unique(),
category: releaseGateCategoryEnum("category").notNull(),
nameSv: text("name_sv").notNull(),
nameEn: text("name_en").notNull(),
descriptionSv: text("description_sv"),
descriptionEn: text("description_en"),
/** Target threshold (01 for ratios, absolute count for counts). */
targetValue: doublePrecision("target_value").notNull(),
comparison: releaseGateComparisonEnum("comparison").notNull(),
/** How many days of analytics events to include in the evaluation. */
evaluationWindowDays: integer("evaluation_window_days").notNull().default(7),
/** Latest computed value, if any. */
lastValue: doublePrecision("last_value"),
lastEvaluatedAt: timestamp("last_evaluated_at", { withTimezone: true }),
status: releaseGateStatusEnum("status").notNull().default("pending"),
/** True if this gate blocks a broad launch until passed. */
blocking: boolean("blocking").notNull().default(false),
/** Free-form admin notes (e.g., why a gate was waived). */
notes: text("notes"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index("release_gates_category_idx").on(t.category),
index("release_gates_status_idx").on(t.status),
],
);
@@ -0,0 +1,93 @@
import { describe, expect, it, beforeEach, afterAll } from "vitest";
import { eq } from "drizzle-orm";
import { createDatabase, closeDatabase } from "../src/client.js";
import { schema, seedReleaseGates, evaluateReleaseGates, summarizeReleaseGates } from "../src/index.js";
import { BUILT_IN_RELEASE_GATES } from "@app/shared-types";
const { db, pool } = createDatabase();
async function reset() {
await db.delete(schema.productAnalyticsEvents);
await db.delete(schema.releaseGates);
await db
.delete(schema.users)
.where(eq(schema.users.email, "release-gates-test@example.invalid"));
}
async function createTestUser(id: string) {
const [user] = await db
.insert(schema.users)
.values({
id,
email: `release-gates-test-${id.slice(0, 8)}@example.invalid`,
displayName: "Release Gate Test",
locale: "sv-SE",
})
.returning({ id: schema.users.id });
return user!.id;
}
beforeEach(async () => {
await reset();
});
afterAll(async () => {
await reset();
await closeDatabase();
});
describe("release gates", () => {
it("seeds built-in gates idempotently", async () => {
await seedReleaseGates(db);
const first = await db.select().from(schema.releaseGates);
expect(first.length).toBe(BUILT_IN_RELEASE_GATES.length);
await seedReleaseGates(db);
const second = await db.select().from(schema.releaseGates);
expect(second.length).toBe(BUILT_IN_RELEASE_GATES.length);
});
it("evaluates first_scan_rate from product_analytics_events", async () => {
await seedReleaseGates(db);
const userA = await createTestUser("11111111-1111-1111-1111-111111111111");
const userB = await createTestUser("22222222-2222-2222-2222-222222222222");
const now = new Date();
await db.insert(schema.productAnalyticsEvents).values([
{ eventName: "account_created", userId: userA, occurredAt: now, receivedAt: now, properties: {} },
{ eventName: "account_created", userId: userB, occurredAt: now, receivedAt: now, properties: {} },
{ eventName: "scan_completed", userId: userA, occurredAt: now, receivedAt: now, properties: {} },
]);
const results = await evaluateReleaseGates(db);
const firstScan = results.find((g) => g.gateKey === "first_scan_rate");
expect(firstScan).toBeDefined();
expect(firstScan?.value).toBeCloseTo(0.5);
expect(firstScan?.status).toBe("failed"); // target 70%
});
it("summarizes overall go/no-go verdict", async () => {
await seedReleaseGates(db);
const results = await evaluateReleaseGates(db);
const summary = summarizeReleaseGates(results);
expect(summary.overall).toBeOneOf(["go", "no_go", "pending"]);
expect(summary.passed + summary.failed + summary.pending + summary.notMeasurable).toBe(
BUILT_IN_RELEASE_GATES.length,
);
});
it("persists evaluation results", async () => {
await seedReleaseGates(db);
const user = await createTestUser("33333333-3333-3333-3333-333333333333");
const now = new Date();
await db.insert(schema.productAnalyticsEvents).values([
{ eventName: "account_created", userId: user, occurredAt: now, receivedAt: now, properties: {} },
{ eventName: "scan_completed", userId: user, occurredAt: now, receivedAt: now, properties: {} },
]);
await evaluateReleaseGates(db);
const [gate] = await db.select().from(schema.releaseGates).where(eq(schema.releaseGates.gateKey, "first_scan_rate"));
expect(gate?.lastValue).toBeCloseTo(1);
expect(gate?.status).toBe("passed");
expect(gate?.lastEvaluatedAt).not.toBeNull();
});
});